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

View file

@ -2,13 +2,14 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from aiooncue import LoginFailedException, Oncue
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.helpers.aiohttp_client import async_get_clientsession
@ -22,30 +23,26 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize the oncue config flow."""
self.reauth_entry: ConfigEntry | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
try:
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:
if not (errors := await self._async_validate_or_error(user_input)):
normalized_username = user_input[CONF_USERNAME].lower()
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(
title=normalized_username, data=user_input
)
@ -60,3 +57,54 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN):
),
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%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth_confirm": {
"description": "Re-authenticate Oncue account {username}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
@ -14,7 +20,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"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 unittest.mock import patch
from aiooncue import OncueDevice, OncueSensor
from aiooncue import LoginFailedException, OncueDevice, OncueSensor
MOCK_ASYNC_FETCH_ALL = {
"123456": OncueDevice(
@ -861,3 +861,21 @@ def _patch_login_and_data_unavailable_device():
yield
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.components.oncue.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -42,7 +43,7 @@ async def test_form(hass: HomeAssistant) -> None:
"username": "TEST-username",
"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:
@ -64,7 +65,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
)
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:
@ -138,3 +139,54 @@ async def test_already_configured(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.ABORT
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 datetime import timedelta
from unittest.mock import patch
from aiooncue import LoginFailedException
@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
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:
@ -67,3 +69,26 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None:
await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}})
await hass.async_block_till_done()
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"