Raise ConfigEntryAuthFailed at Home Connect update auth error (#136953)

* Raise `ConfigEntryAuthFailed` on `UnauthorizedError` handling

* Implement reauth flow

* Add tests

* Remove unnecessary code from tests
This commit is contained in:
J. Diego Rodríguez Royo 2025-02-09 12:36:08 +01:00 committed by GitHub
parent 80cff85c14
commit 4a8c96471b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 4 deletions

View File

@ -1,7 +1,12 @@
"""Config flow for Home Connect."""
from collections.abc import Mapping
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
@ -20,3 +25,29 @@ class OAuth2FlowHandler(
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=data,
)
return await super().async_oauth_create_entry(data)

View File

@ -26,12 +26,14 @@ from aiohomeconnect.model.error import (
HomeConnectApiError,
HomeConnectError,
HomeConnectRequestError,
UnauthorizedError,
)
from aiohomeconnect.model.program import EnumerateProgram
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -270,6 +272,12 @@ class HomeConnectCoordinator(
"""Fetch data from Home Connect."""
try:
appliances = await self.client.get_home_appliances()
except UnauthorizedError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@ -7,9 +7,14 @@
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Home Connect integration needs to re-authenticate your account"
}
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
@ -22,6 +27,9 @@
}
},
"exceptions": {
"auth_error": {
"message": "Authentication error: {error}. Please, re-authenticate your account"
},
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
},

View File

@ -1,7 +1,8 @@
"""Test the Home Connect config flow."""
from collections.abc import Awaitable, Callable
from http import HTTPStatus
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
import pytest
@ -93,3 +94,57 @@ async def test_prevent_multiple_config_entries(
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reauth_flow(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test reauth flow."""
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
_client = await hass_client_no_auth()
resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.home_connect.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"

View File

@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
from aiohomeconnect.const import OAUTH2_TOKEN
from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError
import pytest
import requests_mock
import respx
@ -216,7 +216,16 @@ async def test_token_refresh_success(
)
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(HomeConnectError(), ConfigEntryState.SETUP_RETRY),
(UnauthorizedError("error.key"), ConfigEntryState.SETUP_ERROR),
],
)
async def test_client_error(
exception: HomeConnectError,
expected_state: ConfigEntryState,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
@ -224,10 +233,10 @@ async def test_client_error(
) -> None:
"""Test client errors during setup integration."""
client_with_exception.get_home_appliances.return_value = None
client_with_exception.get_home_appliances.side_effect = HomeConnectError()
client_with_exception.get_home_appliances.side_effect = exception
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert not await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.SETUP_RETRY
assert config_entry.state == expected_state
assert client_with_exception.get_home_appliances.call_count == 1