mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 04:07:08 +00:00
Allow multiple config entries in Home Connect (#143935)
* Allow multiple config entries in Home Connect * Config entry migration * Create new entry if reauth flow is completed with other account * Abort if different account on reauth
This commit is contained in:
parent
819be719ef
commit
4061314cd2
@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from aiohomeconnect.client import Client as HomeConnectClient
|
from aiohomeconnect.client import Client as HomeConnectClient
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import jwt
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
@ -110,14 +111,19 @@ async def async_migrate_entry(
|
|||||||
"""Migrate old entry."""
|
"""Migrate old entry."""
|
||||||
_LOGGER.debug("Migrating from version %s", entry.version)
|
_LOGGER.debug("Migrating from version %s", entry.version)
|
||||||
|
|
||||||
if entry.version == 1 and entry.minor_version == 1:
|
if entry.version == 1:
|
||||||
|
match entry.minor_version:
|
||||||
|
case 1:
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def update_unique_id(
|
def update_unique_id(
|
||||||
entity_entry: RegistryEntry,
|
entity_entry: RegistryEntry,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update unique ID of entity entry."""
|
"""Update unique ID of entity entry."""
|
||||||
for old_id_suffix, new_id_suffix in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items():
|
for (
|
||||||
|
old_id_suffix,
|
||||||
|
new_id_suffix,
|
||||||
|
) in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items():
|
||||||
if entity_entry.unique_id.endswith(f"-{old_id_suffix}"):
|
if entity_entry.unique_id.endswith(f"-{old_id_suffix}"):
|
||||||
return {
|
return {
|
||||||
"new_unique_id": entity_entry.unique_id.replace(
|
"new_unique_id": entity_entry.unique_id.replace(
|
||||||
@ -129,6 +135,15 @@ async def async_migrate_entry(
|
|||||||
await async_migrate_entries(hass, entry.entry_id, update_unique_id)
|
await async_migrate_entries(hass, entry.entry_id, update_unique_id)
|
||||||
|
|
||||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||||
|
case 2:
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
minor_version=3,
|
||||||
|
unique_id=jwt.decode(
|
||||||
|
entry.data["token"]["access_token"],
|
||||||
|
options={"verify_signature": False},
|
||||||
|
)["sub"],
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug("Migration to version %s successful", entry.version)
|
_LOGGER.debug("Migration to version %s successful", entry.version)
|
||||||
return True
|
return True
|
||||||
|
@ -4,6 +4,7 @@ from collections.abc import Mapping
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||||
@ -19,7 +20,7 @@ class OAuth2FlowHandler(
|
|||||||
|
|
||||||
DOMAIN = DOMAIN
|
DOMAIN = DOMAIN
|
||||||
|
|
||||||
MINOR_VERSION = 2
|
MINOR_VERSION = 3
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logger(self) -> logging.Logger:
|
def logger(self) -> logging.Logger:
|
||||||
@ -45,9 +46,15 @@ class OAuth2FlowHandler(
|
|||||||
|
|
||||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||||
"""Create an oauth config entry or update existing entry for reauth."""
|
"""Create an oauth config entry or update existing entry for reauth."""
|
||||||
if self.source == SOURCE_REAUTH:
|
await self.async_set_unique_id(
|
||||||
return self.async_update_reload_and_abort(
|
jwt.decode(
|
||||||
self._get_reauth_entry(),
|
data["token"]["access_token"], options={"verify_signature": False}
|
||||||
data_updates=data,
|
)["sub"]
|
||||||
)
|
)
|
||||||
|
if self.source == SOURCE_REAUTH:
|
||||||
|
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reauth_entry(), data_updates=data
|
||||||
|
)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
return await super().async_oauth_create_entry(data)
|
return await super().async_oauth_create_entry(data)
|
||||||
|
@ -8,6 +8,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aiohomeconnect"],
|
"loggers": ["aiohomeconnect"],
|
||||||
"requirements": ["aiohomeconnect==0.17.0"],
|
"requirements": ["aiohomeconnect==0.17.0"],
|
||||||
"single_config_entry": true,
|
|
||||||
"zeroconf": ["_homeconnect._tcp.local."]
|
"zeroconf": ["_homeconnect._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -14,13 +14,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
|
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||||
|
"wrong_account": "Please ensure you reconfigure against the same account."
|
||||||
},
|
},
|
||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
|
@ -46,7 +46,11 @@ from tests.common import MockConfigEntry, load_fixture
|
|||||||
|
|
||||||
CLIENT_ID = "1234"
|
CLIENT_ID = "1234"
|
||||||
CLIENT_SECRET = "5678"
|
CLIENT_SECRET = "5678"
|
||||||
FAKE_ACCESS_TOKEN = "some-access-token"
|
FAKE_ACCESS_TOKEN = (
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||||
|
".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
|
||||||
|
".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||||
|
)
|
||||||
FAKE_REFRESH_TOKEN = "some-refresh-token"
|
FAKE_REFRESH_TOKEN = "some-refresh-token"
|
||||||
FAKE_AUTH_IMPL = "conftest-imported-cred"
|
FAKE_AUTH_IMPL = "conftest-imported-cred"
|
||||||
|
|
||||||
@ -84,7 +88,8 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry:
|
|||||||
"auth_implementation": FAKE_AUTH_IMPL,
|
"auth_implementation": FAKE_AUTH_IMPL,
|
||||||
"token": token_entry,
|
"token": token_entry,
|
||||||
},
|
},
|
||||||
minor_version=2,
|
minor_version=3,
|
||||||
|
unique_id="1234567890",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -101,6 +106,19 @@ def mock_config_entry_v1_1(token_entry: dict[str, Any]) -> MockConfigEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="config_entry_v1_2")
|
||||||
|
def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry:
|
||||||
|
"""Fixture for a config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"auth_implementation": FAKE_AUTH_IMPL,
|
||||||
|
"token": token_entry,
|
||||||
|
},
|
||||||
|
minor_version=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||||
"""Fixture to setup credentials."""
|
"""Fixture to setup credentials."""
|
||||||
|
@ -13,10 +13,13 @@ from homeassistant.components.application_credentials import (
|
|||||||
async_import_client_credential,
|
async_import_client_credential,
|
||||||
)
|
)
|
||||||
from homeassistant.components.home_connect.const import DOMAIN
|
from homeassistant.components.home_connect.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
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 homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
from tests.typing import ClientSessionGenerator
|
from tests.typing import ClientSessionGenerator
|
||||||
@ -64,8 +67,8 @@ async def test_full_flow(
|
|||||||
aioclient_mock.post(
|
aioclient_mock.post(
|
||||||
OAUTH2_TOKEN,
|
OAUTH2_TOKEN,
|
||||||
json={
|
json={
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||||
"access_token": "mock-access-token",
|
"access_token": FAKE_ACCESS_TOKEN,
|
||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"expires_in": 60,
|
"expires_in": 60,
|
||||||
},
|
},
|
||||||
@ -77,23 +80,64 @@ async def test_full_flow(
|
|||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890")
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_prevent_multiple_config_entries(
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_prevent_reconfiguring_same_account(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
config_entry: MockConfigEntry,
|
config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we only allow one config entry."""
|
"""Test we only allow one config entry per account."""
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await setup.async_setup_component(hass, "home_connect", {})
|
||||||
|
|
||||||
|
await async_import_client_credential(
|
||||||
|
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
|
||||||
|
)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
"home_connect", context={"source": config_entries.SOURCE_USER}
|
"home_connect", context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == HTTPStatus.OK
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||||
|
"access_token": FAKE_ACCESS_TOKEN,
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
assert result["type"] == "abort"
|
||||||
assert result["reason"] == "single_instance_allowed"
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("current_request_with_host")
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
@ -129,8 +173,8 @@ async def test_reauth_flow(
|
|||||||
aioclient_mock.post(
|
aioclient_mock.post(
|
||||||
OAUTH2_TOKEN,
|
OAUTH2_TOKEN,
|
||||||
json={
|
json={
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||||
"access_token": "mock-access-token",
|
"access_token": FAKE_ACCESS_TOKEN,
|
||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"expires_in": 60,
|
"expires_in": 60,
|
||||||
},
|
},
|
||||||
@ -142,9 +186,61 @@ async def test_reauth_flow(
|
|||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890")
|
||||||
|
assert entry
|
||||||
|
assert entry.state is ConfigEntryState.LOADED
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "reauth_successful"
|
assert result["reason"] == "reauth_successful"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_reauth_flow_with_different_account(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||||
|
setup_credentials: None,
|
||||||
|
client: MagicMock,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
) -> None:
|
||||||
|
"""Test reauth flow."""
|
||||||
|
result = await config_entry.start_reauth_flow(hass)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_client = await hass_client_no_auth()
|
||||||
|
resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == HTTPStatus.OK
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||||
|
"access_token": (
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||||
|
".eyJzdWIiOiJBQkNERSIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9"
|
||||||
|
".Q9z9JT4qgNg9Y9ki61jzvd69j043GFWJk9HNYosAPzs"
|
||||||
|
),
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "wrong_account"
|
||||||
|
@ -358,3 +358,20 @@ async def test_bsh_key_transformations() -> None:
|
|||||||
program = "Dishcare.Dishwasher.Program.Eco50"
|
program = "Dishcare.Dishwasher.Program.Eco50"
|
||||||
translation_key = bsh_key_to_translation_key(program)
|
translation_key = bsh_key_to_translation_key(program)
|
||||||
assert RE_TRANSLATION_KEY.match(translation_key)
|
assert RE_TRANSLATION_KEY.match(translation_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_entry_unique_id_migration(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry_v1_2: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that old config entries use the unique id obtained from the JWT subject."""
|
||||||
|
config_entry_v1_2.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert config_entry_v1_2.unique_id != "1234567890"
|
||||||
|
assert config_entry_v1_2.minor_version == 2
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry_v1_2.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert config_entry_v1_2.unique_id == "1234567890"
|
||||||
|
assert config_entry_v1_2.minor_version == 3
|
||||||
|
Loading…
x
Reference in New Issue
Block a user