From 8d72443fd6191462f1fb91e0e2403bb9fd56dda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 9 Dec 2024 15:47:40 +0100 Subject: [PATCH] Set unique_id in myuplink config entry (#132672) --- homeassistant/components/myuplink/__init__.py | 28 +++++++++++++++ .../components/myuplink/config_flow.py | 13 +++++++ .../components/myuplink/strings.json | 1 + tests/components/myuplink/conftest.py | 24 +++++++++++-- tests/components/myuplink/const.py | 1 + tests/components/myuplink/test_config_flow.py | 8 +++-- tests/components/myuplink/test_init.py | 36 ++++++++++++++++++- 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index d801f27817d..c3ff8b6988b 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations from http import HTTPStatus +import logging from aiohttp import ClientError, ClientResponseError +import jwt from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name from homeassistant.config_entries import ConfigEntry @@ -22,6 +24,8 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES from .coordinator import MyUplinkDataCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -109,3 +113,27 @@ async def async_remove_config_entry_device( return not device_entry.identifiers.intersection( (DOMAIN, device_id) for device_id in myuplink_data.data.devices ) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: MyUplinkConfigEntry +) -> bool: + """Migrate old entry.""" + + # Use sub(ject) from access_token as unique_id + if config_entry.version == 1 and config_entry.minor_version == 1: + token = jwt.decode( + config_entry.data["token"]["access_token"], + options={"verify_signature": False}, + ) + uid = token["sub"] + hass.config_entries.async_update_entry( + config_entry, unique_id=uid, minor_version=2 + ) + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py index 554347cfd19..15bff643185 100644 --- a/homeassistant/components/myuplink/config_flow.py +++ b/homeassistant/components/myuplink/config_flow.py @@ -4,6 +4,8 @@ from collections.abc import Mapping import logging from typing import Any +import jwt + from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -15,6 +17,8 @@ class OAuth2FlowHandler( ): """Config flow to handle myUplink OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -46,8 +50,17 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create or update the config entry.""" + + token = jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + ) + uid = token["sub"] + await self.async_set_unique_id(uid) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 997c6fe54b6..bd60a3c7bb3 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -23,6 +23,7 @@ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "account_mismatch": "The used account does not match the original account", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { diff --git a/tests/components/myuplink/conftest.py b/tests/components/myuplink/conftest.py index 9ede11146ef..3ab186b61a8 100644 --- a/tests/components/myuplink/conftest.py +++ b/tests/components/myuplink/conftest.py @@ -15,10 +15,11 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.myuplink.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.json import json_loads -from .const import CLIENT_ID, CLIENT_SECRET +from .const import CLIENT_ID, CLIENT_SECRET, UNIQUE_ID from tests.common import MockConfigEntry, load_fixture @@ -33,7 +34,7 @@ def mock_expires_at() -> float: def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( - version=1, + minor_version=2, domain=DOMAIN, title="myUplink test", data={ @@ -48,6 +49,7 @@ def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry }, }, entry_id="myuplink_test", + unique_id=UNIQUE_ID, ) config_entry.add_to_hass(hass) return config_entry @@ -189,3 +191,21 @@ async def setup_platform( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() yield + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "sub": UNIQUE_ID, + "aud": [], + "scp": [ + "WRITESYSTEM", + "READSYSTEM", + "offline_access", + ], + "ou_code": "NA", + }, + ) diff --git a/tests/components/myuplink/const.py b/tests/components/myuplink/const.py index 6001cb151c0..4cb6db952f1 100644 --- a/tests/components/myuplink/const.py +++ b/tests/components/myuplink/const.py @@ -2,3 +2,4 @@ CLIENT_ID = "12345" CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py index c24d26057de..509af19db8c 100644 --- a/tests/components/myuplink/test_config_flow.py +++ b/tests/components/myuplink/test_config_flow.py @@ -29,6 +29,7 @@ async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + access_token: str, setup_credentials, ) -> None: """Check full flow.""" @@ -59,7 +60,7 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": 60, }, @@ -81,6 +82,7 @@ async def test_flow_reauth( aioclient_mock: AiohttpClientMocker, setup_credentials: None, mock_config_entry: MockConfigEntry, + access_token: str, expires_at: float, ) -> None: """Test reauth step.""" @@ -89,7 +91,7 @@ async def test_flow_reauth( OLD_SCOPE_TOKEN = { "auth_implementation": DOMAIN, "token": { - "access_token": "Fake_token", + "access_token": access_token, "scope": OLD_SCOPE, "expires_in": 86399, "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", @@ -137,7 +139,7 @@ async def test_flow_reauth( OAUTH2_TOKEN, json={ "refresh_token": "updated-refresh-token", - "access_token": "updated-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": "60", "scope": CURRENT_SCOPE, diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index b474db731d1..440002311e9 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration +from .const import UNIQUE_ID from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -92,7 +93,40 @@ async def test_devices_multiple_created_count( mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that multiple device are created.""" + """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) assert len(device_registry.devices) == 2 + + +async def test_migrate_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_myuplink_client: MagicMock, + expires_at: float, + access_token: str, +) -> None: + """Test migration of config entry.""" + mock_entry_v1_1 = MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="myUplink test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "scope": "WRITESYSTEM READSYSTEM offline_access", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="myuplink_test", + ) + + await setup_integration(hass, mock_entry_v1_1) + assert mock_entry_v1_1.version == 1 + assert mock_entry_v1_1.minor_version == 2 + assert mock_entry_v1_1.unique_id == UNIQUE_ID