Fix authentication issues for asekopool (#99495)

* fix: handle authentication issues for asekopool

* fix: handle authentication issues for asekopool

* feat: add config migration

* feat: add re-authentication step

* fix: add reauth message

* fix: add tests for config flow

* fix: tests clean up

* Update homeassistant/components/aseko_pool_live/__init__.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/aseko_pool_live/__init__.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* fix: Reformat code

* Fix bad merge

* Really fix bad merge

* Update config_flow.py

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
hopkins-tk 2024-03-04 17:20:46 +01:00 committed by GitHub
parent 91b2dd4b83
commit 3d1fbe444e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 59 deletions

View file

@ -3,12 +3,12 @@ from __future__ import annotations
import logging import logging
from aioaseko import APIUnavailable, MobileAccount from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant 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 from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -22,11 +22,15 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Aseko Pool Live from a config entry.""" """Set up Aseko Pool Live from a config entry."""
account = MobileAccount( account = MobileAccount(
async_get_clientsession(hass), access_token=entry.data[CONF_ACCESS_TOKEN] async_get_clientsession(hass),
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
) )
try: try:
units = await account.get_units() units = await account.get_units()
except InvalidAuthCredentials as err:
raise ConfigEntryAuthFailed from err
except APIUnavailable as err: except APIUnavailable as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
@ -48,3 +52,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {
CONF_EMAIL: config_entry.title,
CONF_PASSWORD: "",
}
hass.config_entries.async_update_entry(config_entry, data=new, version=2)
_LOGGER.debug("Migration to version %s successful", config_entry.version)
return True
_LOGGER.error("Attempt to migrate from unknown version %s", config_entry.version)
return False

View file

@ -1,19 +1,15 @@
"""Config flow for Aseko Pool Live integration.""" """Config flow for Aseko Pool Live 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
from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount, WebAccount from aioaseko import APIUnavailable, InvalidAuthCredentials, WebAccount
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 ( from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
CONF_ACCESS_TOKEN,
CONF_EMAIL,
CONF_PASSWORD,
CONF_UNIQUE_ID,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -24,7 +20,16 @@ _LOGGER = logging.getLogger(__name__)
class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aseko Pool Live.""" """Handle a config flow for Aseko Pool Live."""
VERSION = 1 VERSION = 2
data_schema = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
reauth_entry: ConfigEntry | None = None
async def get_account_info(self, email: str, password: str) -> dict: async def get_account_info(self, email: str, password: str) -> dict:
"""Get account info from the mobile API and the web API.""" """Get account info from the mobile API and the web API."""
@ -33,19 +38,83 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
web_account = WebAccount(session, email, password) web_account = WebAccount(session, email, password)
web_account_info = await web_account.login() web_account_info = await web_account.login()
mobile_account = MobileAccount(session, email, password)
await mobile_account.login()
return { return {
CONF_ACCESS_TOKEN: mobile_account.access_token, CONF_EMAIL: email,
CONF_EMAIL: web_account_info.email, CONF_PASSWORD: password,
CONF_UNIQUE_ID: web_account_info.user_id, CONF_UNIQUE_ID: web_account_info.user_id,
} }
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
self.reauth_entry = None
errors = {}
if user_input is not None:
try:
info = await self.get_account_info(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except APIUnavailable:
errors["base"] = "cannot_connect"
except InvalidAuthCredentials:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self.async_store_credentials(info)
return self.async_show_form(
step_id="user",
data_schema=self.data_schema,
errors=errors,
)
async def async_store_credentials(self, info: dict[str, Any]) -> ConfigFlowResult:
"""Store validated credentials."""
if self.reauth_entry:
self.hass.config_entries.async_update_entry(
self.reauth_entry,
title=info[CONF_EMAIL],
data={
CONF_EMAIL: info[CONF_EMAIL],
CONF_PASSWORD: info[CONF_PASSWORD],
},
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
await self.async_set_unique_id(info[CONF_UNIQUE_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info[CONF_EMAIL],
data={
CONF_EMAIL: info[CONF_EMAIL],
CONF_PASSWORD: info[CONF_PASSWORD],
},
)
async def async_step_reauth(
self, user_input: 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(user_input)
async def async_step_reauth_confirm(
self, user_input: Mapping | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
try: try:
@ -60,21 +129,10 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(info[CONF_UNIQUE_ID]) return await self.async_store_credentials(info)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info[CONF_EMAIL],
data={CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN]},
)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="reauth_confirm",
data_schema=vol.Schema( data_schema=self.data_schema,
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors, errors=errors,
) )

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live", "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioaseko"], "loggers": ["aioaseko"],
"requirements": ["aioaseko==0.0.2"] "requirements": ["aioaseko==0.1.1"]
} }

View file

@ -6,6 +6,12 @@
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
},
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
} }
}, },
"error": { "error": {
@ -14,7 +20,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%]"
} }
}, },
"entity": { "entity": {

View file

@ -200,7 +200,7 @@ aioambient==2024.01.0
aioapcaccess==0.4.2 aioapcaccess==0.4.2
# homeassistant.components.aseko_pool_live # homeassistant.components.aseko_pool_live
aioaseko==0.0.2 aioaseko==0.1.1
# homeassistant.components.asuswrt # homeassistant.components.asuswrt
aioasuswrt==1.4.0 aioasuswrt==1.4.0

View file

@ -179,7 +179,7 @@ aioambient==2024.01.0
aioapcaccess==0.4.2 aioapcaccess==0.4.2
# homeassistant.components.aseko_pool_live # homeassistant.components.aseko_pool_live
aioaseko==0.0.2 aioaseko==0.1.1
# homeassistant.components.asuswrt # homeassistant.components.asuswrt
aioasuswrt==1.4.0 aioasuswrt==1.4.0

View file

@ -1,37 +1,40 @@
"""Test the Aseko Pool Live config flow.""" """Test the Aseko Pool Live config flow."""
from unittest.mock import AsyncMock, patch from unittest.mock import patch
from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials
import pytest import pytest
from homeassistant import config_entries, setup from homeassistant import config_entries
from homeassistant.components.aseko_pool_live.const import DOMAIN from homeassistant.components.aseko_pool_live.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD 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 def test_form(hass: HomeAssistant) -> None:
async def test_async_step_user_form(hass: HomeAssistant) -> None:
"""Test we get the form.""" """Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
async def test_async_step_user_success(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch( with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
), patch( ), patch(
"homeassistant.components.aseko_pool_live.config_flow.MobileAccount",
) as mock_mobile_account, patch(
"homeassistant.components.aseko_pool_live.async_setup_entry", "homeassistant.components.aseko_pool_live.async_setup_entry",
return_value=True, return_value=True,
) as mock_setup_entry: ) as mock_setup_entry:
mobile_account = mock_mobile_account.return_value
mobile_account.login = AsyncMock()
mobile_account.access_token = "any_access_token"
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
@ -43,25 +46,25 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "aseko@example.com" assert result2["title"] == "aseko@example.com"
assert result2["data"] == {CONF_ACCESS_TOKEN: "any_access_token"} assert result2["data"] == {
CONF_EMAIL: "aseko@example.com",
CONF_PASSWORD: "passw0rd",
}
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
("error_web", "error_mobile", "reason"), ("error_web", "reason"),
[ [
(APIUnavailable, None, "cannot_connect"), (APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, None, "invalid_auth"), (InvalidAuthCredentials, "invalid_auth"),
(Exception, None, "unknown"), (Exception, "unknown"),
(None, APIUnavailable, "cannot_connect"),
(None, InvalidAuthCredentials, "invalid_auth"),
(None, Exception, "unknown"),
], ],
) )
async def test_get_account_info_exceptions( async def test_async_step_user_exception(
hass: HomeAssistant, error_web: Exception, error_mobile: Exception, reason: str hass: HomeAssistant, error_web: Exception, reason: str
) -> None: ) -> None:
"""Test we handle config flow exceptions.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
@ -70,9 +73,120 @@ async def test_get_account_info_exceptions(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
side_effect=error_web, side_effect=error_web,
), patch( ):
"homeassistant.components.aseko_pool_live.config_flow.MobileAccount.login", result2 = await hass.config_entries.flow.async_configure(
side_effect=error_mobile, result["flow_id"],
{
CONF_EMAIL: "aseko@example.com",
CONF_PASSWORD: "passw0rd",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": reason}
@pytest.mark.parametrize(
("error_web", "reason"),
[
(APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"),
(Exception, "unknown"),
],
)
async def test_get_account_info_exceptions(
hass: HomeAssistant, error_web: Exception, reason: str
) -> None:
"""Test we handle config flow exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
side_effect=error_web,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "aseko@example.com",
CONF_PASSWORD: "passw0rd",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": reason}
async def test_async_step_reauth_success(hass: HomeAssistant) -> None:
"""Test successful reauthentication."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="UID",
data={CONF_EMAIL: "aseko@example.com"},
)
mock_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_entry.entry_id,
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "aseko@example.com", CONF_PASSWORD: "passw0rd"},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("error_web", "reason"),
[
(APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"),
(Exception, "unknown"),
],
)
async def test_async_step_reauth_exception(
hass: HomeAssistant, error_web: Exception, reason: str
) -> None:
"""Test we get the form."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="UID",
data={CONF_EMAIL: "aseko@example.com"},
)
mock_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_entry.entry_id,
},
)
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
side_effect=error_web,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],