Skip to content

Commit 579ffcc

Browse files
authored
Add unique_id to senz config_entry (home-assistant#156472)
1 parent 81943fb commit 579ffcc

File tree

6 files changed

+153
-3
lines changed

6 files changed

+153
-3
lines changed

homeassistant/components/senz/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from aiosenz import SENZAPI, Thermostat
99
from httpx import RequestError
10+
import jwt
1011

1112
from homeassistant.config_entries import ConfigEntry
1213
from homeassistant.const import Platform
@@ -82,3 +83,27 @@ async def update_thermostats() -> dict[str, Thermostat]:
8283
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
8384
"""Unload a config entry."""
8485
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
86+
87+
88+
async def async_migrate_entry(
89+
hass: HomeAssistant, config_entry: SENZConfigEntry
90+
) -> bool:
91+
"""Migrate old entry."""
92+
93+
# Use sub(ject) from access_token as unique_id
94+
if config_entry.version == 1 and config_entry.minor_version == 1:
95+
token = jwt.decode(
96+
config_entry.data["token"]["access_token"],
97+
options={"verify_signature": False},
98+
)
99+
uid = token["sub"]
100+
hass.config_entries.async_update_entry(
101+
config_entry, unique_id=uid, minor_version=2
102+
)
103+
_LOGGER.info(
104+
"Migration to version %s.%s successful",
105+
config_entry.version,
106+
config_entry.minor_version,
107+
)
108+
109+
return True

homeassistant/components/senz/config_flow.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import logging
44

5+
import jwt
6+
7+
from homeassistant.config_entries import ConfigFlowResult
58
from homeassistant.helpers import config_entry_oauth2_flow
69

710
from .const import DOMAIN
@@ -12,6 +15,8 @@ class OAuth2FlowHandler(
1215
):
1316
"""Config flow to handle SENZ OAuth2 authentication."""
1417

18+
VERSION = 1
19+
MINOR_VERSION = 2
1520
DOMAIN = DOMAIN
1621

1722
@property
@@ -23,3 +28,15 @@ def logger(self) -> logging.Logger:
2328
def extra_authorize_data(self) -> dict:
2429
"""Extra data that needs to be appended to the authorize url."""
2530
return {"scope": "restapi offline_access"}
31+
32+
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
33+
"""Create or update the config entry."""
34+
35+
token = jwt.decode(
36+
data["token"]["access_token"], options={"verify_signature": False}
37+
)
38+
uid = token["sub"]
39+
await self.async_set_unique_id(uid)
40+
41+
self._abort_if_unique_id_configured()
42+
return await super().async_oauth_create_entry(data)

tests/components/senz/conftest.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
)
1515
from homeassistant.components.senz.const import DOMAIN
1616
from homeassistant.core import HomeAssistant
17+
from homeassistant.helpers import config_entry_oauth2_flow
1718
from homeassistant.setup import async_setup_component
1819

19-
from .const import CLIENT_ID, CLIENT_SECRET
20+
from .const import CLIENT_ID, CLIENT_SECRET, ENTRY_UNIQUE_ID
2021

2122
from tests.common import (
2223
MockConfigEntry,
@@ -63,7 +64,7 @@ def mock_expires_at() -> float:
6364
def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry:
6465
"""Return the default mocked config entry."""
6566
config_entry = MockConfigEntry(
66-
minor_version=1,
67+
minor_version=2,
6768
domain=DOMAIN,
6869
title="Senz test",
6970
data={
@@ -77,6 +78,7 @@ def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry
7778
},
7879
},
7980
entry_id="senz_test",
81+
unique_id=ENTRY_UNIQUE_ID,
8082
)
8183
config_entry.add_to_hass(hass)
8284
return config_entry
@@ -109,3 +111,20 @@ async def setup_credentials(hass: HomeAssistant) -> None:
109111
),
110112
DOMAIN,
111113
)
114+
115+
116+
@pytest.fixture
117+
async def access_token(hass: HomeAssistant) -> str:
118+
"""Return a valid access token."""
119+
return config_entry_oauth2_flow._encode_jwt(
120+
hass,
121+
{
122+
"sub": ENTRY_UNIQUE_ID,
123+
"aud": [],
124+
"scp": [
125+
"rest_api",
126+
"offline_access",
127+
],
128+
"ou_code": "NA",
129+
},
130+
)

tests/components/senz/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22

33
CLIENT_ID = "test_client_id"
44
CLIENT_SECRET = "test_client_secret"
5+
6+
ENTRY_UNIQUE_ID = "test_unique_id"

tests/components/senz/test_config_flow.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
)
1313
from homeassistant.components.senz.const import DOMAIN
1414
from homeassistant.core import HomeAssistant
15+
from homeassistant.data_entry_flow import FlowResultType
1516
from homeassistant.helpers import config_entry_oauth2_flow
1617
from homeassistant.setup import async_setup_component
1718

1819
from .const import CLIENT_ID, CLIENT_SECRET
1920

21+
from tests.common import MockConfigEntry
2022
from tests.test_util.aiohttp import AiohttpClientMocker
2123
from tests.typing import ClientSessionGenerator
2224

@@ -26,6 +28,7 @@ async def test_full_flow(
2628
hass: HomeAssistant,
2729
hass_client_no_auth: ClientSessionGenerator,
2830
aioclient_mock: AiohttpClientMocker,
31+
access_token: str,
2932
) -> None:
3033
"""Check full flow."""
3134
await async_setup_component(hass, DOMAIN, {})
@@ -61,7 +64,7 @@ async def test_full_flow(
6164
TOKEN_ENDPOINT,
6265
json={
6366
"refresh_token": "mock-refresh-token",
64-
"access_token": "mock-access-token",
67+
"access_token": access_token,
6568
"type": "Bearer",
6669
"expires_in": 60,
6770
},
@@ -74,3 +77,52 @@ async def test_full_flow(
7477

7578
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
7679
assert len(mock_setup.mock_calls) == 1
80+
81+
82+
@pytest.mark.usefixtures("current_request_with_host")
83+
async def test_duplicate_flow(
84+
hass: HomeAssistant,
85+
hass_client_no_auth: ClientSessionGenerator,
86+
aioclient_mock: AiohttpClientMocker,
87+
mock_config_entry: MockConfigEntry,
88+
access_token: str,
89+
) -> None:
90+
"""Check full flow with duplicate entry."""
91+
mock_config_entry.add_to_hass(hass)
92+
result = await hass.config_entries.flow.async_init(
93+
DOMAIN, context={"source": config_entries.SOURCE_USER}
94+
)
95+
state = config_entry_oauth2_flow._encode_jwt(
96+
hass,
97+
{
98+
"flow_id": result["flow_id"],
99+
"redirect_uri": "https://example.com/auth/external/callback",
100+
},
101+
)
102+
103+
assert result["url"] == (
104+
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
105+
"&redirect_uri=https://example.com/auth/external/callback"
106+
f"&state={state}&scope=restapi+offline_access"
107+
)
108+
109+
client = await hass_client_no_auth()
110+
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
111+
assert resp.status == 200
112+
assert resp.headers["content-type"] == "text/html; charset=utf-8"
113+
114+
aioclient_mock.post(
115+
TOKEN_ENDPOINT,
116+
json={
117+
"refresh_token": "mock-refresh-token",
118+
"access_token": access_token,
119+
"type": "Bearer",
120+
"expires_in": 60,
121+
},
122+
)
123+
124+
with patch("homeassistant.components.senz.async_setup_entry", return_value=True):
125+
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
126+
127+
assert result2["type"] is FlowResultType.ABORT
128+
assert result2["reason"] == "already_configured"

tests/components/senz/test_init.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
from unittest.mock import MagicMock, patch
44

5+
from homeassistant.components.senz.const import DOMAIN
56
from homeassistant.config_entries import ConfigEntryState
67
from homeassistant.core import HomeAssistant
78
from homeassistant.helpers.config_entry_oauth2_flow import (
89
ImplementationUnavailableError,
910
)
1011

1112
from . import setup_integration
13+
from .const import ENTRY_UNIQUE_ID
1214

1315
from tests.common import MockConfigEntry
1416

@@ -43,3 +45,36 @@ async def test_oauth_implementation_not_available(
4345
await hass.async_block_till_done()
4446

4547
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
48+
49+
50+
async def test_migrate_config_entry(
51+
hass: HomeAssistant,
52+
mock_config_entry: MockConfigEntry,
53+
mock_senz_client: MagicMock,
54+
expires_at: float,
55+
access_token: str,
56+
) -> None:
57+
"""Test migration of config entry."""
58+
mock_entry_v1_1 = MockConfigEntry(
59+
version=1,
60+
minor_version=1,
61+
domain=DOMAIN,
62+
title="SENZ test",
63+
data={
64+
"auth_implementation": DOMAIN,
65+
"token": {
66+
"access_token": access_token,
67+
"scope": "rest_api offline_access",
68+
"expires_in": 86399,
69+
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
70+
"token_type": "Bearer",
71+
"expires_at": expires_at,
72+
},
73+
},
74+
entry_id="senz_test",
75+
)
76+
77+
await setup_integration(hass, mock_entry_v1_1)
78+
assert mock_entry_v1_1.version == 1
79+
assert mock_entry_v1_1.minor_version == 2
80+
assert mock_entry_v1_1.unique_id == ENTRY_UNIQUE_ID

0 commit comments

Comments
 (0)