Add reauth step to Hyperion config flow (#43797)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Dermot Duffy 2020-12-16 14:55:31 -08:00 committed by GitHub
parent d0ebc00684
commit aaae452d58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 275 additions and 46 deletions

View file

@ -8,8 +8,8 @@ from hyperion import client, const as hyperion_const
from pkg_resources import parse_version
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
@ -85,6 +85,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def _create_reauth_flow(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_REAUTH}, data=config_entry.data
)
)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Hyperion from a config entry."""
host = config_entry.data[CONF_HOST]
@ -92,8 +103,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
token = config_entry.data.get(CONF_TOKEN)
hyperion_client = await async_create_connect_hyperion_client(
host, port, token=token
host, port, token=token, raw_connection=True
)
# Client won't connect? => Not ready.
if not hyperion_client:
raise ConfigEntryNotReady
version = await hyperion_client.async_sysinfo_version()
@ -110,6 +123,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
except ValueError:
pass
# Client needs authentication, but no token provided? => Reauth.
auth_resp = await hyperion_client.async_is_auth_required()
if (
auth_resp is not None
and client.ResponseOK(auth_resp)
and auth_resp.get(hyperion_const.KEY_INFO, {}).get(
hyperion_const.KEY_REQUIRED, False
)
and token is None
):
await _create_reauth_flow(hass, config_entry)
return False
# Client login doesn't work? => Reauth.
if not await hyperion_client.async_client_login():
await _create_reauth_flow(hass, config_entry)
return False
# Cannot switch instance or cannot load state? => Not ready.
if (
not await hyperion_client.async_client_switch_instance()
or not client.ServerInfoResponseOK(await hyperion_client.async_get_serverinfo())
):
raise ConfigEntryNotReady
hyperion_client.set_callbacks(
{
f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": lambda json: (
@ -139,17 +177,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
]
)
hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].append(
config_entry.add_update_listener(_async_options_updated)
config_entry.add_update_listener(_async_entry_updated)
)
hass.async_create_task(setup_then_listen())
return True
async def _async_options_updated(
async def _async_entry_updated(
hass: HomeAssistantType, config_entry: ConfigEntry
) -> None:
"""Handle options update."""
"""Handle entry updates."""
await hass.config_entries.async_reload(config_entry.entry_id)

View file

@ -12,11 +12,19 @@ import voluptuous as vol
from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL
from homeassistant.config_entries import (
CONN_CLASS_LOCAL_PUSH,
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
OptionsFlow,
)
from homeassistant.const import CONF_BASE, CONF_HOST, CONF_ID, CONF_PORT, CONF_TOKEN
from homeassistant.const import (
CONF_BASE,
CONF_HOST,
CONF_ID,
CONF_PORT,
CONF_SOURCE,
CONF_TOKEN,
)
from homeassistant.core import callback
from homeassistant.helpers.typing import ConfigType
@ -35,13 +43,13 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)
# +------------------+ +------------------+ +--------------------+
# |Step: SSDP | |Step: user | |Step: import |
# | | | | | |
# |Input: <discovery>| |Input: <host/port>| |Input: <import data>|
# +------------------+ +------------------+ +--------------------+
# v v v
# +----------------------+-----------------------+
# +------------------+ +------------------+ +--------------------+ +--------------------+
# |Step: SSDP | |Step: user | |Step: import | |Step: reauth |
# | | | | | | | |
# |Input: <discovery>| |Input: <host/port>| |Input: <import data>| |Input: <entry_data> |
# +------------------+ +------------------+ +--------------------+ +--------------------+
# v v v v
# +-------------------+-----------------------+--------------------+
# Auth not | Auth |
# required? | required? |
# | v
@ -82,7 +90,7 @@ _LOGGER.setLevel(logging.DEBUG)
# |
# v
# +----------------+
# | Create! |
# | Create/Update! |
# +----------------+
# A note on choice of discovery mechanisms: Hyperion supports both Zeroconf and SSDP out
@ -140,6 +148,17 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="cannot_connect")
return await self._advance_to_auth_step_if_necessary(hyperion_client)
async def async_step_reauth(
self,
config_data: ConfigType,
) -> Dict[str, Any]:
"""Handle a reauthentication flow."""
self._data = dict(config_data)
async with self._create_client(raw_connection=True) as hyperion_client:
if not hyperion_client:
return self.async_abort(reason="cannot_connect")
return await self._advance_to_auth_step_if_necessary(hyperion_client)
async def async_step_ssdp( # type: ignore[override]
self, discovery_info: Dict[str, Any]
) -> Dict[str, Any]:
@ -401,7 +420,18 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN):
if not hyperion_id:
return self.async_abort(reason="no_id")
await self.async_set_unique_id(hyperion_id, raise_on_progress=False)
entry = await self.async_set_unique_id(hyperion_id, raise_on_progress=False)
# pylint: disable=no-member
if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and entry is not None:
assert self.hass
self.hass.config_entries.async_update_entry(entry, data=self._data)
# Need to manually reload, as the listener won't have been installed because
# the initial load did not succeed (the reauth flow will not be initiated if
# the load succeeds)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
self._abort_if_unique_id_configured()
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167

View file

@ -16,8 +16,6 @@ CONF_ON_UNLOAD = "ON_UNLOAD"
SIGNAL_INSTANCES_UPDATED = f"{DOMAIN}_instances_updated_signal." "{}"
SIGNAL_INSTANCE_REMOVED = f"{DOMAIN}_instance_removed_signal." "{}"
SOURCE_IMPORT = "import"
HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9"
HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases"

View file

@ -21,7 +21,7 @@ from homeassistant.components.light import (
SUPPORT_EFFECT,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TOKEN
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
@ -47,7 +47,6 @@ from .const import (
DOMAIN,
SIGNAL_INSTANCE_REMOVED,
SIGNAL_INSTANCES_UPDATED,
SOURCE_IMPORT,
TYPE_HYPERION_LIGHT,
)

View file

@ -37,7 +37,8 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI",
"auth_new_token_not_work_error": "Failed to authenticate using newly created token",
"no_id": "The Hyperion Ambilight instance did not report its id"
"no_id": "The Hyperion Ambilight instance did not report its id",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {

View file

@ -50,6 +50,20 @@ TEST_INSTANCE_3: Dict[str, Any] = {
"running": True,
}
TEST_AUTH_REQUIRED_RESP: Dict[str, Any] = {
"command": "authorize-tokenRequired",
"info": {
"required": True,
},
"success": True,
"tan": 1,
}
TEST_AUTH_NOT_REQUIRED_RESP = {
**TEST_AUTH_REQUIRED_RESP,
"info": {"required": False},
}
_LOGGER = logging.getLogger(__name__)
@ -78,12 +92,7 @@ def create_mock_client() -> Mock:
mock_client.async_client_connect = AsyncMock(return_value=True)
mock_client.async_client_disconnect = AsyncMock(return_value=True)
mock_client.async_is_auth_required = AsyncMock(
return_value={
"command": "authorize-tokenRequired",
"info": {"required": False},
"success": True,
"tan": 1,
}
return_value=TEST_AUTH_NOT_REQUIRED_RESP
)
mock_client.async_login = AsyncMock(
return_value={"command": "authorize-login", "success": True, "tan": 0}
@ -91,6 +100,17 @@ def create_mock_client() -> Mock:
mock_client.async_sysinfo_id = AsyncMock(return_value=TEST_SYSINFO_ID)
mock_client.async_sysinfo_version = AsyncMock(return_value=TEST_SYSINFO_ID)
mock_client.async_client_switch_instance = AsyncMock(return_value=True)
mock_client.async_client_login = AsyncMock(return_value=True)
mock_client.async_get_serverinfo = AsyncMock(
return_value={
"command": "serverinfo",
"success": True,
"tan": 0,
"info": {"fake": "data"},
}
)
mock_client.adjustment = None
mock_client.effects = None
mock_client.instances = [
@ -100,12 +120,15 @@ def create_mock_client() -> Mock:
return mock_client
def add_test_config_entry(hass: HomeAssistantType) -> ConfigEntry:
def add_test_config_entry(
hass: HomeAssistantType, data: Optional[Dict[str, Any]] = None
) -> ConfigEntry:
"""Add a test config entry."""
config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call]
entry_id=TEST_CONFIG_ENTRY_ID,
domain=DOMAIN,
data={
data=data
or {
CONF_HOST: TEST_HOST,
CONF_PORT: TEST_PORT,
},
@ -118,10 +141,12 @@ def add_test_config_entry(hass: HomeAssistantType) -> ConfigEntry:
async def setup_test_config_entry(
hass: HomeAssistantType, hyperion_client: Optional[Mock] = None
hass: HomeAssistantType,
config_entry: Optional[ConfigEntry] = None,
hyperion_client: Optional[Mock] = None,
) -> ConfigEntry:
"""Add a test Hyperion entity to hass."""
config_entry = add_test_config_entry(hass)
config_entry = config_entry or add_test_config_entry(hass)
hyperion_client = hyperion_client or create_mock_client()
# pylint: disable=attribute-defined-outside-init

View file

@ -11,10 +11,14 @@ from homeassistant.components.hyperion.const import (
CONF_CREATE_TOKEN,
CONF_PRIORITY,
DOMAIN,
SOURCE_IMPORT,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER
from homeassistant.config_entries import (
SOURCE_IMPORT,
SOURCE_REAUTH,
SOURCE_SSDP,
SOURCE_USER,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
@ -25,6 +29,7 @@ from homeassistant.const import (
from homeassistant.helpers.typing import HomeAssistantType
from . import (
TEST_AUTH_REQUIRED_RESP,
TEST_CONFIG_ENTRY_ID,
TEST_ENTITY_ID_1,
TEST_HOST,
@ -49,15 +54,6 @@ TEST_HOST_PORT: Dict[str, Any] = {
CONF_PORT: TEST_PORT,
}
TEST_AUTH_REQUIRED_RESP = {
"command": "authorize-tokenRequired",
"info": {
"required": True,
},
"success": True,
"tan": 1,
}
TEST_AUTH_ID = "ABCDE"
TEST_REQUEST_TOKEN_SUCCESS = {
"command": "authorize-requestToken",
@ -694,3 +690,62 @@ async def test_options(hass: HomeAssistantType) -> None:
blocking=True,
)
assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority
async def test_reauth_success(hass: HomeAssistantType) -> None:
"""Check a reauth flow that succeeds."""
config_data = {
CONF_HOST: TEST_HOST,
CONF_PORT: TEST_PORT,
}
config_entry = add_test_config_entry(hass, data=config_data)
client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
), patch("homeassistant.components.hyperion.async_setup", return_value=True), patch(
"homeassistant.components.hyperion.async_setup_entry", return_value=True
):
result = await _init_flow(
hass,
source=SOURCE_REAUTH,
data=config_data,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: False, CONF_TOKEN: TEST_TOKEN}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful"
assert CONF_TOKEN in config_entry.data
async def test_reauth_cannot_connect(hass: HomeAssistantType) -> None:
"""Check a reauth flow that fails to connect."""
config_data = {
CONF_HOST: TEST_HOST,
CONF_PORT: TEST_PORT,
}
add_test_config_entry(hass, data=config_data)
client = create_mock_client()
client.async_client_connect = AsyncMock(return_value=False)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
result = await _init_flow(
hass,
source=SOURCE_REAUTH,
data=config_data,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "cannot_connect"

View file

@ -17,12 +17,26 @@ from homeassistant.components.light import (
ATTR_HS_COLOR,
DOMAIN as LIGHT_DOMAIN,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.config_entries import (
ENTRY_STATE_SETUP_ERROR,
SOURCE_REAUTH,
ConfigEntry,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_PORT,
CONF_SOURCE,
CONF_TOKEN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.helpers.typing import HomeAssistantType
from . import (
TEST_AUTH_NOT_REQUIRED_RESP,
TEST_AUTH_REQUIRED_RESP,
TEST_CONFIG_ENTRY_OPTIONS,
TEST_ENTITY_ID_1,
TEST_ENTITY_ID_2,
@ -206,7 +220,9 @@ async def test_setup_config_entry(hass: HomeAssistantType) -> None:
assert hass.states.get(TEST_ENTITY_ID_1) is not None
async def test_setup_config_entry_not_ready(hass: HomeAssistantType) -> None:
async def test_setup_config_entry_not_ready_connect_fail(
hass: HomeAssistantType,
) -> None:
"""Test the component not being ready."""
client = create_mock_client()
client.async_client_connect = AsyncMock(return_value=False)
@ -214,6 +230,32 @@ async def test_setup_config_entry_not_ready(hass: HomeAssistantType) -> None:
assert hass.states.get(TEST_ENTITY_ID_1) is None
async def test_setup_config_entry_not_ready_switch_instance_fail(
hass: HomeAssistantType,
) -> None:
"""Test the component not being ready."""
client = create_mock_client()
client.async_client_switch_instance = AsyncMock(return_value=False)
await setup_test_config_entry(hass, hyperion_client=client)
assert hass.states.get(TEST_ENTITY_ID_1) is None
async def test_setup_config_entry_not_ready_load_state_fail(
hass: HomeAssistantType,
) -> None:
"""Test the component not being ready."""
client = create_mock_client()
client.async_get_serverinfo = AsyncMock(
return_value={
"command": "serverinfo",
"success": False,
}
)
await setup_test_config_entry(hass, hyperion_client=client)
assert hass.states.get(TEST_ENTITY_ID_1) is None
async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> None:
"""Test dynamic changes in the omstamce configuration."""
config_entry = add_test_config_entry(hass)
@ -724,7 +766,7 @@ async def test_unload_entry(hass: HomeAssistantType) -> None:
client = create_mock_client()
await setup_test_config_entry(hass, hyperion_client=client)
assert hass.states.get(TEST_ENTITY_ID_1) is not None
assert client.async_client_connect.called
assert client.async_client_connect.call_count == 2
assert not client.async_client_disconnect.called
entry = _get_config_entry_from_unique_id(hass, TEST_SYSINFO_ID)
assert entry
@ -749,3 +791,44 @@ async def test_version_no_log_warning(caplog, hass: HomeAssistantType) -> None:
await setup_test_config_entry(hass, hyperion_client=client)
assert hass.states.get(TEST_ENTITY_ID_1) is not None
assert "Please consider upgrading" not in caplog.text
async def test_setup_entry_no_token_reauth(hass: HomeAssistantType) -> None:
"""Verify a reauth flow when auth is required but no token provided."""
client = create_mock_client()
config_entry = add_test_config_entry(hass)
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
assert not await hass.config_entries.async_setup(config_entry.entry_id)
mock_flow_init.assert_called_once_with(
DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
data=config_entry.data,
)
assert config_entry.state == ENTRY_STATE_SETUP_ERROR
async def test_setup_entry_bad_token_reauth(hass: HomeAssistantType) -> None:
"""Verify a reauth flow when a bad token is provided."""
client = create_mock_client()
config_entry = add_test_config_entry(
hass,
data={CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, CONF_TOKEN: "expired_token"},
)
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_NOT_REQUIRED_RESP)
# Fail to log in.
client.async_client_login = AsyncMock(return_value=False)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
assert not await hass.config_entries.async_setup(config_entry.entry_id)
mock_flow_init.assert_called_once_with(
DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
data=config_entry.data,
)
assert config_entry.state == ENTRY_STATE_SETUP_ERROR