Add reauth to A. O. Smith integration (#105320)

* Add reauth to A. O. Smith integration

* Validate reauth uses the same email address

* Only show password field during reauth
This commit is contained in:
Brandon Rothweiler 2023-12-10 17:30:24 -05:00 committed by GitHub
parent b5af987a18
commit 58d9d0daa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 170 additions and 19 deletions

View File

@ -1,6 +1,7 @@
"""Config flow for A. O. Smith integration.""" """Config flow for A. O. Smith integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@ -22,6 +23,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_reauth_email: str | None
def __init__(self):
"""Start the config flow."""
self._reauth_email = None
async def _async_validate_credentials(
self, email: str, password: str
) -> str | None:
"""Validate the credentials. Return an error string, or None if successful."""
session = aiohttp_client.async_get_clientsession(self.hass)
client = AOSmithAPIClient(email, password, session)
try:
await client.get_devices()
except AOSmithInvalidCredentialsException:
return "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return "unknown"
return 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
) -> FlowResult: ) -> FlowResult:
@ -32,30 +56,56 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
session = aiohttp_client.async_get_clientsession(self.hass) error = await self._async_validate_credentials(
client = AOSmithAPIClient( user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session
) )
if error is None:
try:
await client.get_devices()
except AOSmithInvalidCredentialsException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input title=user_input[CONF_EMAIL], data=user_input
) )
errors["base"] = error
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_EMAIL): str, vol.Required(CONF_EMAIL, default=self._reauth_email): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
} }
), ),
errors=errors, errors=errors,
) )
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth if the user credentials have changed."""
self._reauth_email = entry_data[CONF_EMAIL]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user's reauth credentials."""
errors: dict[str, str] = {}
if user_input is not None and self._reauth_email is not None:
email = self._reauth_email
password = user_input[CONF_PASSWORD]
entry_id = self.context["entry_id"]
if entry := self.hass.config_entries.async_get_entry(entry_id):
error = await self._async_validate_credentials(email, password)
if error is None:
self.hass.config_entries.async_update_entry(
entry,
data=entry.data | user_input,
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
errors["base"] = error
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_EMAIL: self._reauth_email},
errors=errors,
)

View File

@ -10,6 +10,7 @@ from py_aosmith import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL
@ -29,7 +30,9 @@ class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Fetch latest data from API.""" """Fetch latest data from API."""
try: try:
devices = await self.client.get_devices() devices = await self.client.get_devices()
except (AOSmithInvalidCredentialsException, AOSmithUnknownException) as err: except AOSmithInvalidCredentialsException as err:
raise ConfigEntryAuthFailed from err
except AOSmithUnknownException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err raise UpdateFailed(f"Error communicating with API: {err}") from err
mode_pending = any( mode_pending = any(

View File

@ -7,6 +7,13 @@
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"description": "Please enter your A. O. Smith credentials." "description": "Please enter your A. O. Smith credentials."
},
"reauth_confirm": {
"description": "Please update your password for {email}",
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
} }
}, },
"error": { "error": {
@ -14,7 +21,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]" "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
} }
} }

View File

@ -24,7 +24,7 @@ def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry( return MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=FIXTURE_USER_INPUT, data=FIXTURE_USER_INPUT,
unique_id="unique_id", unique_id=FIXTURE_USER_INPUT[CONF_EMAIL],
) )

View File

@ -1,15 +1,18 @@
"""Test the A. O. Smith config flow.""" """Test the A. O. Smith config flow."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from py_aosmith import AOSmithInvalidCredentialsException from py_aosmith import AOSmithInvalidCredentialsException
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.aosmith.const import DOMAIN from homeassistant.components.aosmith.const import DOMAIN, REGULAR_INTERVAL
from homeassistant.const import CONF_EMAIL from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.aosmith.conftest import FIXTURE_USER_INPUT from tests.components.aosmith.conftest import FIXTURE_USER_INPUT
@ -82,3 +85,90 @@ async def test_form_exception(
assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL]
assert result3["data"] == FIXTURE_USER_INPUT assert result3["data"] == FIXTURE_USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth_flow(
freezer: FrozenDateTimeFactory,
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test reauth works."""
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException(
"Authentication error"
)
freezer.tick(REGULAR_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices",
return_value=[],
), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"],
{CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
async def test_reauth_flow_retry(
freezer: FrozenDateTimeFactory,
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test reauth works with retry."""
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException(
"Authentication error"
)
freezer.tick(REGULAR_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
# First attempt at reauth - authentication fails again
with patch(
"homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices",
side_effect=AOSmithInvalidCredentialsException("Authentication error"),
):
result2 = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"],
{CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
# Second attempt at reauth - authentication succeeds
with patch(
"homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices",
return_value=[],
), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True):
result3 = await hass.config_entries.flow.async_configure(
flows[0]["flow_id"],
{CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"