Store tplink credentials_hash outside of device_config (#120597)

This commit is contained in:
Steven B 2024-06-27 13:34:12 +01:00 committed by GitHub
parent 0d53ce4fb8
commit 970dd99226
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 373 additions and 31 deletions

View file

@ -43,6 +43,7 @@ from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
CONNECT_TIMEOUT,
DISCOVERY_TIMEOUT,
@ -73,6 +74,7 @@ def async_trigger_discovery(
discovered_devices: dict[str, Device],
) -> None:
"""Trigger config flows for discovered devices."""
for formatted_mac, device in discovered_devices.items():
discovery_flow.async_create_flow(
hass,
@ -83,7 +85,6 @@ def async_trigger_discovery(
CONF_HOST: device.host,
CONF_MAC: formatted_mac,
CONF_DEVICE_CONFIG: device.config.to_dict(
credentials_hash=device.credentials_hash,
exclude_credentials=True,
),
},
@ -133,6 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
"""Set up TPLink from a config entry."""
host: str = entry.data[CONF_HOST]
credentials = await get_credentials(hass)
entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH)
config: DeviceConfig | None = None
if config_dict := entry.data.get(CONF_DEVICE_CONFIG):
@ -151,19 +153,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
config.timeout = CONNECT_TIMEOUT
if config.uses_http is True:
config.http_client = create_async_tplink_clientsession(hass)
# If we have in memory credentials use them otherwise check for credentials_hash
if credentials:
config.credentials = credentials
elif entry_credentials_hash:
config.credentials_hash = entry_credentials_hash
try:
device: Device = await Device.connect(config=config)
except AuthenticationError as ex:
# If the stored credentials_hash was used but doesn't work remove it
if not credentials and entry_credentials_hash:
data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH}
hass.config_entries.async_update_entry(entry, data=data)
raise ConfigEntryAuthFailed from ex
except KasaException as ex:
raise ConfigEntryNotReady from ex
device_config_dict = device.config.to_dict(
credentials_hash=device.credentials_hash, exclude_credentials=True
)
device_credentials_hash = device.credentials_hash
device_config_dict = device.config.to_dict(exclude_credentials=True)
# Do not store the credentials hash inside the device_config
device_config_dict.pop(CONF_CREDENTIALS_HASH, None)
updates: dict[str, Any] = {}
if device_credentials_hash and device_credentials_hash != entry_credentials_hash:
updates[CONF_CREDENTIALS_HASH] = device_credentials_hash
if device_config_dict != config_dict:
updates[CONF_DEVICE_CONFIG] = device_config_dict
if entry.data.get(CONF_ALIAS) != device.alias:
@ -326,7 +340,25 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
minor_version = 3
hass.config_entries.async_update_entry(config_entry, minor_version=3)
_LOGGER.debug("Migration to version %s.%s successful", version, minor_version)
if version == 1 and minor_version == 3:
# credentials_hash stored in the device_config should be moved to data.
updates: dict[str, Any] = {}
if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG):
assert isinstance(config_dict, dict)
if credentials_hash := config_dict.pop(CONF_CREDENTIALS_HASH, None):
updates[CONF_CREDENTIALS_HASH] = credentials_hash
updates[CONF_DEVICE_CONFIG] = config_dict
minor_version = 4
hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
**updates,
},
minor_version=minor_version,
)
_LOGGER.debug("Migration to version %s.%s complete", version, minor_version)
return True

View file

@ -44,7 +44,13 @@ from . import (
mac_alias,
set_credentials,
)
from .const import CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DOMAIN
from .const import (
CONF_CONNECTION_TYPE,
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
CONNECT_TIMEOUT,
DOMAIN,
)
STEP_AUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
@ -55,7 +61,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for tplink."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 4
reauth_entry: ConfigEntry | None = None
def __init__(self) -> None:
@ -95,9 +101,18 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG)
if entry_config_dict == config and entry_data[CONF_HOST] == host:
return None
updates = {**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host}
# If the connection parameters have changed the credentials_hash will be invalid.
if (
entry_config_dict
and isinstance(entry_config_dict, dict)
and entry_config_dict.get(CONF_CONNECTION_TYPE)
!= config.get(CONF_CONNECTION_TYPE)
):
updates.pop(CONF_CREDENTIALS_HASH, None)
return self.async_update_reload_and_abort(
entry,
data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host},
data=updates,
reason="already_configured",
)
@ -345,18 +360,22 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
@callback
def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult:
"""Create a config entry from a smart device."""
# This is only ever called after a successful device update so we know that
# the credential_hash is correct and should be saved.
self._abort_if_unique_id_configured(updates={CONF_HOST: device.host})
data = {
CONF_HOST: device.host,
CONF_ALIAS: device.alias,
CONF_MODEL: device.model,
CONF_DEVICE_CONFIG: device.config.to_dict(
exclude_credentials=True,
),
}
if device.credentials_hash:
data[CONF_CREDENTIALS_HASH] = device.credentials_hash
return self.async_create_entry(
title=f"{device.alias} {device.model}",
data={
CONF_HOST: device.host,
CONF_ALIAS: device.alias,
CONF_MODEL: device.model,
CONF_DEVICE_CONFIG: device.config.to_dict(
credentials_hash=device.credentials_hash,
exclude_credentials=True,
),
},
data=data,
)
async def _async_try_discover_and_update(

View file

@ -20,6 +20,8 @@ ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh"
ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh"
CONF_DEVICE_CONFIG: Final = "device_config"
CONF_CREDENTIALS_HASH: Final = "credentials_hash"
CONF_CONNECTION_TYPE: Final = "connection_type"
PLATFORMS: Final = [
Platform.BINARY_SENSOR,

View file

@ -22,6 +22,7 @@ from syrupy import SnapshotAssertion
from homeassistant.components.tplink import (
CONF_ALIAS,
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
CONF_HOST,
CONF_MODEL,
@ -53,9 +54,7 @@ MAC_ADDRESS2 = "11:22:33:44:55:66"
DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}"
CREDENTIALS_HASH_LEGACY = ""
DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS)
DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(
credentials_hash=CREDENTIALS_HASH_LEGACY, exclude_credentials=True
)
DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(exclude_credentials=True)
CREDENTIALS = Credentials("foo", "bar")
CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv=="
DEVICE_CONFIG_AUTH = DeviceConfig(
@ -74,12 +73,8 @@ DEVICE_CONFIG_AUTH2 = DeviceConfig(
),
uses_http=True,
)
DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict(
credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True
)
DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict(
credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True
)
DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True)
DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict(exclude_credentials=True)
CREATE_ENTRY_DATA_LEGACY = {
CONF_HOST: IP_ADDRESS,
@ -92,14 +87,20 @@ CREATE_ENTRY_DATA_AUTH = {
CONF_HOST: IP_ADDRESS,
CONF_ALIAS: ALIAS,
CONF_MODEL: MODEL,
CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AUTH,
CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH,
}
CREATE_ENTRY_DATA_AUTH2 = {
CONF_HOST: IP_ADDRESS2,
CONF_ALIAS: ALIAS,
CONF_MODEL: MODEL,
CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AUTH,
CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH2,
}
NEW_CONNECTION_TYPE = DeviceConnectionParameters(
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Aes
)
NEW_CONNECTION_TYPE_DICT = NEW_CONNECTION_TYPE.to_dict()
def _load_feature_fixtures():

View file

@ -14,8 +14,12 @@ from homeassistant.components.tplink import (
DeviceConfig,
KasaException,
)
from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG
from homeassistant.config_entries import ConfigEntryState
from homeassistant.components.tplink.const import (
CONF_CONNECTION_TYPE,
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import (
CONF_ALIAS,
CONF_DEVICE,
@ -32,6 +36,7 @@ from . import (
CREATE_ENTRY_DATA_AUTH,
CREATE_ENTRY_DATA_AUTH2,
CREATE_ENTRY_DATA_LEGACY,
CREDENTIALS_HASH_AUTH,
DEFAULT_ENTRY_TITLE,
DEVICE_CONFIG_DICT_AUTH,
DEVICE_CONFIG_DICT_LEGACY,
@ -40,6 +45,7 @@ from . import (
MAC_ADDRESS,
MAC_ADDRESS2,
MODULE,
NEW_CONNECTION_TYPE_DICT,
_mocked_device,
_patch_connect,
_patch_discovery,
@ -811,6 +817,77 @@ async def test_integration_discovery_with_ip_change(
mock_connect["connect"].assert_awaited_once_with(config=config)
async def test_integration_discovery_with_connection_change(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_discovery: AsyncMock,
mock_connect: AsyncMock,
) -> None:
"""Test that config entry is updated with new device config.
And that connection_hash is removed as it will be invalid.
"""
mock_connect["connect"].side_effect = KasaException()
mock_config_entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=CREATE_ENTRY_DATA_AUTH,
unique_id=MAC_ADDRESS,
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink.Discover.discover", return_value={}):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert (
len(
hass.config_entries.flow.async_progress_by_handler(
DOMAIN, match_context={"source": SOURCE_REAUTH}
)
)
== 0
)
assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH
assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1"
assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_AUTH
NEW_DEVICE_CONFIG = {
**DEVICE_CONFIG_DICT_AUTH,
CONF_CONNECTION_TYPE: NEW_CONNECTION_TYPE_DICT,
}
config = DeviceConfig.from_dict(NEW_DEVICE_CONFIG)
# Reset the connect mock so when the config flow reloads the entry it succeeds
mock_connect["connect"].reset_mock(side_effect=True)
bulb = _mocked_device(
device_config=config,
mac=mock_config_entry.unique_id,
)
mock_connect["connect"].return_value = bulb
discovery_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
CONF_HOST: "127.0.0.1",
CONF_MAC: MAC_ADDRESS,
CONF_ALIAS: ALIAS,
CONF_DEVICE_CONFIG: NEW_DEVICE_CONFIG,
},
)
await hass.async_block_till_done(wait_background_tasks=True)
assert discovery_result["type"] is FlowResultType.ABORT
assert discovery_result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_DEVICE_CONFIG] == NEW_DEVICE_CONFIG
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
assert CREDENTIALS_HASH_AUTH not in mock_config_entry.data
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_connect["connect"].assert_awaited_once_with(config=config)
async def test_dhcp_discovery_with_ip_change(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View file

@ -7,12 +7,16 @@ from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
from kasa import AuthenticationError, Feature, KasaException, Module
from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module
import pytest
from homeassistant import setup
from homeassistant.components import tplink
from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG, DOMAIN
from homeassistant.components.tplink.const import (
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import (
CONF_AUTHENTICATION,
@ -458,7 +462,214 @@ async def test_unlink_devices(
expected_identifiers = identifiers[:expected_count]
assert device_entries[0].identifiers == set(expected_identifiers)
assert entry.version == 1
assert entry.minor_version == 3
assert entry.minor_version == 4
msg = f"{expected_message} identifiers for device dummy (hs300): {set(identifiers)}"
assert msg in caplog.text
async def test_move_credentials_hash(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test credentials hash moved to parent.
As async_setup_entry will succeed the hash on the parent is updated
from the device.
"""
device_config = {
**DEVICE_CONFIG_AUTH.to_dict(
exclude_credentials=True, credentials_hash="theHash"
)
}
entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config}
entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=entry_data,
entry_id="123456",
unique_id=MAC_ADDRESS,
version=1,
minor_version=3,
)
assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash"
entry.add_to_hass(hass)
async def _connect(config):
config.credentials_hash = "theNewHash"
return _mocked_device(device_config=config, credentials_hash="theNewHash")
with (
patch("homeassistant.components.tplink.Device.connect", new=_connect),
patch("homeassistant.components.tplink.PLATFORMS", []),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 4
assert entry.state is ConfigEntryState.LOADED
assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG]
assert CONF_CREDENTIALS_HASH in entry.data
# Gets the new hash from the successful connection.
assert entry.data[CONF_CREDENTIALS_HASH] == "theNewHash"
assert "Migration to version 1.4 complete" in caplog.text
async def test_move_credentials_hash_auth_error(
hass: HomeAssistant,
) -> None:
"""Test credentials hash moved to parent.
If there is an auth error it should be deleted after migration
in async_setup_entry.
"""
device_config = {
**DEVICE_CONFIG_AUTH.to_dict(
exclude_credentials=True, credentials_hash="theHash"
)
}
entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config}
entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=entry_data,
unique_id=MAC_ADDRESS,
version=1,
minor_version=3,
)
assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash"
with (
patch(
"homeassistant.components.tplink.Device.connect",
side_effect=AuthenticationError,
),
patch("homeassistant.components.tplink.PLATFORMS", []),
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 4
assert entry.state is ConfigEntryState.SETUP_ERROR
assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG]
# Auth failure deletes the hash
assert CONF_CREDENTIALS_HASH not in entry.data
async def test_move_credentials_hash_other_error(
hass: HomeAssistant,
) -> None:
"""Test credentials hash moved to parent.
When there is a KasaException the same hash should still be on the parent
at the end of the test.
"""
device_config = {
**DEVICE_CONFIG_AUTH.to_dict(
exclude_credentials=True, credentials_hash="theHash"
)
}
entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config}
entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=entry_data,
unique_id=MAC_ADDRESS,
version=1,
minor_version=3,
)
assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash"
with (
patch(
"homeassistant.components.tplink.Device.connect", side_effect=KasaException
),
patch("homeassistant.components.tplink.PLATFORMS", []),
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 4
assert entry.state is ConfigEntryState.SETUP_RETRY
assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG]
assert CONF_CREDENTIALS_HASH in entry.data
assert entry.data[CONF_CREDENTIALS_HASH] == "theHash"
async def test_credentials_hash(
hass: HomeAssistant,
) -> None:
"""Test credentials_hash used to call connect."""
device_config = {**DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True)}
entry_data = {
**CREATE_ENTRY_DATA_AUTH,
CONF_DEVICE_CONFIG: device_config,
CONF_CREDENTIALS_HASH: "theHash",
}
entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=entry_data,
unique_id=MAC_ADDRESS,
)
async def _connect(config):
config.credentials_hash = "theHash"
return _mocked_device(device_config=config, credentials_hash="theHash")
with (
patch("homeassistant.components.tplink.PLATFORMS", []),
patch("homeassistant.components.tplink.Device.connect", new=_connect),
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG]
assert CONF_CREDENTIALS_HASH in entry.data
assert entry.data[CONF_DEVICE_CONFIG] == device_config
assert entry.data[CONF_CREDENTIALS_HASH] == "theHash"
async def test_credentials_hash_auth_error(
hass: HomeAssistant,
) -> None:
"""Test credentials_hash is deleted after an auth failure."""
device_config = {**DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True)}
entry_data = {
**CREATE_ENTRY_DATA_AUTH,
CONF_DEVICE_CONFIG: device_config,
CONF_CREDENTIALS_HASH: "theHash",
}
entry = MockConfigEntry(
title="TPLink",
domain=DOMAIN,
data=entry_data,
unique_id=MAC_ADDRESS,
)
with (
patch("homeassistant.components.tplink.PLATFORMS", []),
patch(
"homeassistant.components.tplink.Device.connect",
side_effect=AuthenticationError,
) as connect_mock,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
expected_config = DeviceConfig.from_dict(
DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True, credentials_hash="theHash")
)
connect_mock.assert_called_with(config=expected_config)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert CONF_CREDENTIALS_HASH not in entry.data