Add reauth to fujitsu_fglair (#124166)

* Add reauth to fujitsu_fglair

* Add test for reauth when an exception occurs.

* Address comments

* Always assert

* Address comments
This commit is contained in:
Antoine Reversat 2024-08-19 05:04:34 -04:00 committed by GitHub
parent 2577fb804b
commit d9aa931fac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 163 additions and 8 deletions

View File

@ -1,12 +1,13 @@
"""Config flow for Fujitsu HVAC (based on Ayla IOT) integration.""" """Config flow for Fujitsu HVAC (based on Ayla IOT) integration."""
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from ayla_iot_unofficial import AylaAuthError, new_ayla_api from ayla_iot_unofficial import AylaAuthError, new_ayla_api
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -22,11 +23,18 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_EUROPE): bool, vol.Required(CONF_EUROPE): bool,
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class FGLairConfigFlow(ConfigFlow, domain=DOMAIN): class FGLairConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fujitsu HVAC (based on Ayla IOT).""" """Handle a config flow for Fujitsu HVAC (based on Ayla IOT)."""
_reauth_entry: ConfigEntry | None = None
async def _async_validate_credentials( async def _async_validate_credentials(
self, user_input: dict[str, Any] self, user_input: dict[str, Any]
) -> dict[str, str]: ) -> dict[str, str]:
@ -71,3 +79,41 @@ class FGLairConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
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
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
assert self._reauth_entry
if user_input:
reauth_data = {
**self._reauth_entry.data,
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
errors = await self._async_validate_credentials(reauth_data)
if len(errors) == 0:
return self.async_update_reload_and_abort(
self._reauth_entry, data=reauth_data
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
description_placeholders={
CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME],
**self.context["title_placeholders"],
},
errors=errors,
)

View File

@ -6,7 +6,7 @@ from ayla_iot_unofficial import AylaApi, AylaAuthError
from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import API_REFRESH from .const import API_REFRESH
@ -31,7 +31,7 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]):
try: try:
await self.api.async_sign_in() await self.api.async_sign_in()
except AylaAuthError as e: except AylaAuthError as e:
raise ConfigEntryError("Credentials expired for Ayla IoT API") from e raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e
async def _async_update_data(self) -> dict[str, FujitsuHVAC]: async def _async_update_data(self) -> dict[str, FujitsuHVAC]:
"""Fetch data from api endpoint.""" """Fetch data from api endpoint."""
@ -45,7 +45,7 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]):
devices = await self.api.async_get_devices() devices = await self.api.async_get_devices()
except AylaAuthError as e: except AylaAuthError as e:
raise ConfigEntryError("Credentials expired for Ayla IoT API") from e raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e
if len(listening_entities) == 0: if len(listening_entities) == 0:
devices = list(filter(lambda x: isinstance(x, FujitsuHVAC), devices)) devices = list(filter(lambda x: isinstance(x, FujitsuHVAC), devices))
@ -58,6 +58,6 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]):
for dev in devices: for dev in devices:
await dev.async_update() await dev.async_update()
except AylaAuthError as e: except AylaAuthError as e:
raise ConfigEntryError("Credentials expired for Ayla IoT API") from e raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e
return {d.device_serial_number: d for d in devices} return {d.device_serial_number: d for d in devices}

View File

@ -11,6 +11,13 @@
"data_description": { "data_description": {
"is_europe": "Allows the user to choose whether to use european servers or not since the API uses different endoint URLs for european vs non-european users" "is_europe": "Allows the user to choose whether to use european servers or not since the API uses different endoint URLs for european vs non-european users"
} }
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Please re-enter the password for {username}:",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
} }
}, },
"error": { "error": {
@ -19,7 +26,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_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
} }
} }

View File

@ -6,12 +6,12 @@ from ayla_iot_unofficial import AylaAuthError
import pytest import pytest
from homeassistant.components.fujitsu_fglair.const import CONF_EUROPE, DOMAIN from homeassistant.components.fujitsu_fglair.const import CONF_EUROPE, DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.data_entry_flow import FlowResult, FlowResultType
from .conftest import TEST_PASSWORD, TEST_USERNAME from .conftest import TEST_PASSWORD, TEST_PASSWORD2, TEST_USERNAME
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -105,3 +105,104 @@ async def test_form_exceptions(
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_EUROPE: False, CONF_EUROPE: False,
} }
async def test_reauth_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_ayla_api: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
"title_placeholders": {"name": "test"},
},
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_EUROPE: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: TEST_PASSWORD2,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2
@pytest.mark.parametrize(
("exception", "err_msg"),
[
(AylaAuthError, "invalid_auth"),
(TimeoutError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_reauth_exceptions(
hass: HomeAssistant,
exception: Exception,
err_msg: str,
mock_setup_entry: AsyncMock,
mock_ayla_api: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow when an exception occurs."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
"title_placeholders": {"name": "test"},
},
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_EUROPE: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_ayla_api.async_sign_in.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: TEST_PASSWORD2,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": err_msg}
mock_ayla_api.async_sign_in.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: TEST_PASSWORD2,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2