Add reauth support to oncue (#115667)

* Add reauth support to oncue

* review comments

* reauth on update failure

* coverage
This commit is contained in:
J. Nick Koston 2024-04-18 09:39:58 -05:00 committed by GitHub
parent 53c48537d7
commit ea8d4d0dca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 29 deletions

View file

@ -5,12 +5,12 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from aiooncue import LoginFailedException, Oncue from aiooncue import LoginFailedException, Oncue, OncueDevice
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -29,17 +29,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await client.async_login() await client.async_login()
except CONNECTION_EXCEPTIONS as ex: except CONNECTION_EXCEPTIONS as ex:
raise ConfigEntryNotReady(ex) from ex raise ConfigEntryNotReady from ex
except LoginFailedException as ex: except LoginFailedException as ex:
_LOGGER.error("Failed to login to oncue service: %s", ex) raise ConfigEntryAuthFailed from ex
return False
async def _async_update() -> dict[str, OncueDevice]:
"""Fetch data from Oncue."""
try:
return await client.async_fetch_all()
except LoginFailedException as ex:
raise ConfigEntryAuthFailed from ex
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
name=f"Oncue {entry.data[CONF_USERNAME]}", name=f"Oncue {entry.data[CONF_USERNAME]}",
update_interval=timedelta(minutes=10), update_interval=timedelta(minutes=10),
update_method=client.async_fetch_all, update_method=_async_update,
always_update=False, always_update=False,
) )
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View file

@ -2,13 +2,14 @@
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 aiooncue import LoginFailedException, Oncue from aiooncue import LoginFailedException, Oncue
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.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -22,30 +23,26 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self) -> None:
"""Initialize the oncue config flow."""
self.reauth_entry: ConfigEntry | None = 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
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
try: if not (errors := await self._async_validate_or_error(user_input)):
await Oncue(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
async_get_clientsession(self.hass),
).async_login()
except CONNECTION_EXCEPTIONS:
errors["base"] = "cannot_connect"
except LoginFailedException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
normalized_username = user_input[CONF_USERNAME].lower() normalized_username = user_input[CONF_USERNAME].lower()
await self.async_set_unique_id(normalized_username) await self.async_set_unique_id(normalized_username)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured(
updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
)
return self.async_create_entry( return self.async_create_entry(
title=normalized_username, data=user_input title=normalized_username, data=user_input
) )
@ -60,3 +57,54 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN):
), ),
errors=errors, errors=errors,
) )
async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]:
"""Validate the user input."""
errors: dict[str, str] = {}
try:
await Oncue(
config[CONF_USERNAME],
config[CONF_PASSWORD],
async_get_clientsession(self.hass),
).async_login()
except CONNECTION_EXCEPTIONS:
errors["base"] = "cannot_connect"
except LoginFailedException:
errors[CONF_PASSWORD] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
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
description_placeholders: dict[str, str] = {
CONF_USERNAME: existing_data[CONF_USERNAME]
}
if user_input is not None:
new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]}
if not (errors := await self._async_validate_or_error(new_config)):
return self.async_update_reload_and_abort(
existing_entry, data=new_config
)
return self.async_show_form(
description_placeholders=description_placeholders,
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
errors=errors,
)

View file

@ -6,6 +6,12 @@
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
},
"reauth_confirm": {
"description": "Re-authenticate Oncue account {username}",
"data": {
"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%]"
} }
} }
} }

View file

@ -3,7 +3,7 @@
from contextlib import contextmanager from contextlib import contextmanager
from unittest.mock import patch from unittest.mock import patch
from aiooncue import OncueDevice, OncueSensor from aiooncue import LoginFailedException, OncueDevice, OncueSensor
MOCK_ASYNC_FETCH_ALL = { MOCK_ASYNC_FETCH_ALL = {
"123456": OncueDevice( "123456": OncueDevice(
@ -861,3 +861,21 @@ def _patch_login_and_data_unavailable_device():
yield yield
return _patcher() return _patcher()
def _patch_login_and_data_auth_failure():
@contextmanager
def _patcher():
with (
patch(
"homeassistant.components.oncue.Oncue.async_login",
side_effect=LoginFailedException,
),
patch(
"homeassistant.components.oncue.Oncue.async_fetch_all",
side_effect=LoginFailedException,
),
):
yield
return _patcher()

View file

@ -6,6 +6,7 @@ from aiooncue import LoginFailedException
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.oncue.const import DOMAIN from homeassistant.components.oncue.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -42,7 +43,7 @@ async def test_form(hass: HomeAssistant) -> None:
"username": "TEST-username", "username": "TEST-username",
"password": "test-password", "password": "test-password",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert mock_setup_entry.call_count == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None:
@ -64,7 +65,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant) -> None:
@ -138,3 +139,54 @@ async def test_already_configured(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.ABORT assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured" assert result2["reason"] == "already_configured"
async def test_reauth(hass: HomeAssistant) -> None:
"""Test reauth flow."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_USERNAME: "any",
CONF_PASSWORD: "old",
},
)
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]
with patch(
"homeassistant.components.oncue.config_flow.Oncue.async_login",
side_effect=LoginFailedException,
):
result2 = await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"password": "invalid_auth"}
with (
patch("homeassistant.components.oncue.config_flow.Oncue.async_login"),
patch(
"homeassistant.components.oncue.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert config_entry.data[CONF_PASSWORD] == "test-password"
assert mock_setup_entry.call_count == 1

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from aiooncue import LoginFailedException from aiooncue import LoginFailedException
@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState
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.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import _patch_login_and_data from . import _patch_login_and_data, _patch_login_and_data_auth_failure
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
async def test_config_entry_reload(hass: HomeAssistant) -> None: async def test_config_entry_reload(hass: HomeAssistant) -> None:
@ -67,3 +69,26 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None:
await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}})
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_late_auth_failure(hass: HomeAssistant) -> None:
"""Test auth fails after already setup."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_USERNAME: "any", CONF_PASSWORD: "any"},
unique_id="any",
)
config_entry.add_to_hass(hass)
with _patch_login_and_data():
await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}})
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
with _patch_login_and_data_auth_failure():
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10))
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
flow = flows[0]
assert flow["context"]["source"] == "reauth"