mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +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
|
||||
import aiohttp
|
||||
import jwt
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@ -110,25 +111,39 @@ async def async_migrate_entry(
|
||||
"""Migrate old entry."""
|
||||
_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
|
||||
def update_unique_id(
|
||||
entity_entry: RegistryEntry,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update unique ID of entity entry."""
|
||||
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}"):
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
old_id_suffix, new_id_suffix
|
||||
)
|
||||
}
|
||||
return None
|
||||
@callback
|
||||
def update_unique_id(
|
||||
entity_entry: RegistryEntry,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update unique ID of entity entry."""
|
||||
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}"):
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.replace(
|
||||
old_id_suffix, new_id_suffix
|
||||
)
|
||||
}
|
||||
return None
|
||||
|
||||
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)
|
||||
return True
|
||||
|
@ -4,6 +4,7 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
@ -19,7 +20,7 @@ class OAuth2FlowHandler(
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@ -45,9 +46,15 @@ class OAuth2FlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an oauth config entry or update existing entry for reauth."""
|
||||
await self.async_set_unique_id(
|
||||
jwt.decode(
|
||||
data["token"]["access_token"], options={"verify_signature": False}
|
||||
)["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._get_reauth_entry(), data_updates=data
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await super().async_oauth_create_entry(data)
|
||||
|
@ -8,6 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.17.0"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
@ -14,13 +14,15 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"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": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
|
@ -46,7 +46,11 @@ from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
FAKE_ACCESS_TOKEN = "some-access-token"
|
||||
FAKE_ACCESS_TOKEN = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
|
||||
".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
)
|
||||
FAKE_REFRESH_TOKEN = "some-refresh-token"
|
||||
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,
|
||||
"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
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Fixture to setup credentials."""
|
||||
|
@ -13,10 +13,13 @@ from homeassistant.components.application_credentials import (
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
@ -64,8 +67,8 @@ async def test_full_flow(
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
"access_token": FAKE_ACCESS_TOKEN,
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
@ -77,23 +80,64 @@ async def test_full_flow(
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
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
|
||||
|
||||
|
||||
async def test_prevent_multiple_config_entries(
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_prevent_reconfiguring_same_account(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test we only allow one config entry."""
|
||||
"""Test we only allow one config entry per account."""
|
||||
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(
|
||||
"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["reason"] == "single_instance_allowed"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
@ -129,8 +173,8 @@ async def test_reauth_flow(
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
"access_token": FAKE_ACCESS_TOKEN,
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
@ -142,9 +186,61 @@ async def test_reauth_flow(
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
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
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
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"
|
||||
translation_key = bsh_key_to_translation_key(program)
|
||||
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