Skip to content

Commit e02dc53

Browse files
authored
Add reauthentication flow and tests to senz (home-assistant#156534)
1 parent bedae1e commit e02dc53

File tree

5 files changed

+218
-11
lines changed

5 files changed

+218
-11
lines changed

homeassistant/components/senz/__init__.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
from __future__ import annotations
44

55
from datetime import timedelta
6+
from http import HTTPStatus
67
import logging
78

89
from aiosenz import SENZAPI, Thermostat
9-
from httpx import RequestError
10+
from httpx import HTTPStatusError, RequestError
1011
import jwt
1112

1213
from homeassistant.config_entries import ConfigEntry
1314
from homeassistant.const import Platform
1415
from homeassistant.core import HomeAssistant
15-
from homeassistant.exceptions import ConfigEntryNotReady
16+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
1617
from homeassistant.helpers import config_validation as cv, httpx_client
1718
from homeassistant.helpers.config_entry_oauth2_flow import (
1819
ImplementationUnavailableError,
@@ -59,8 +60,21 @@ async def update_thermostats() -> dict[str, Thermostat]:
5960

6061
try:
6162
account = await senz_api.get_account()
63+
except HTTPStatusError as err:
64+
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
65+
raise ConfigEntryAuthFailed(
66+
translation_domain=DOMAIN,
67+
translation_key="config_entry_auth_failed",
68+
) from err
69+
raise ConfigEntryNotReady(
70+
translation_domain=DOMAIN,
71+
translation_key="config_entry_not_ready",
72+
) from err
6273
except RequestError as err:
63-
raise ConfigEntryNotReady from err
74+
raise ConfigEntryNotReady(
75+
translation_domain=DOMAIN,
76+
translation_key="config_entry_not_ready",
77+
) from err
6478

6579
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
6680
hass,

homeassistant/components/senz/config_flow.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Config flow for nVent RAYCHEM SENZ."""
22

3+
from collections.abc import Mapping
34
import logging
5+
from typing import Any
46

57
import jwt
68

7-
from homeassistant.config_entries import ConfigFlowResult
9+
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
810
from homeassistant.helpers import config_entry_oauth2_flow
911

1012
from .const import DOMAIN
@@ -29,6 +31,22 @@ def extra_authorize_data(self) -> dict:
2931
"""Extra data that needs to be appended to the authorize url."""
3032
return {"scope": "restapi offline_access"}
3133

34+
async def async_step_reauth(
35+
self, entry_data: Mapping[str, Any]
36+
) -> ConfigFlowResult:
37+
"""Perform reauth upon an API authentication error."""
38+
39+
return await self.async_step_reauth_confirm()
40+
41+
async def async_step_reauth_confirm(
42+
self, user_input: dict[str, Any] | None = None
43+
) -> ConfigFlowResult:
44+
"""Dialog that informs the user that reauth is required."""
45+
if user_input is None:
46+
return self.async_show_form(step_id="reauth_confirm")
47+
48+
return await self.async_step_user()
49+
3250
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
3351
"""Create or update the config entry."""
3452

@@ -38,5 +56,11 @@ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
3856
uid = token["sub"]
3957
await self.async_set_unique_id(uid)
4058

59+
if self.source == SOURCE_REAUTH:
60+
self._abort_if_unique_id_mismatch(reason="account_mismatch")
61+
return self.async_update_reload_and_abort(
62+
self._get_reauth_entry(), data=data
63+
)
64+
4165
self._abort_if_unique_id_configured()
4266
return await super().async_oauth_create_entry(data)

homeassistant/components/senz/strings.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"config": {
33
"abort": {
4+
"account_mismatch": "The used account does not match the original account",
45
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
56
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
67
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
@@ -9,7 +10,8 @@
910
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
1011
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
1112
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
12-
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]"
13+
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
14+
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
1315
},
1416
"create_entry": {
1517
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -23,10 +25,20 @@
2325
"implementation": "[%key:common::config_flow::description::implementation%]"
2426
},
2527
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
28+
},
29+
"reauth_confirm": {
30+
"description": "The SENZ integration needs to re-authenticate your account",
31+
"title": "[%key:common::config_flow::title::reauth%]"
2632
}
2733
}
2834
},
2935
"exceptions": {
36+
"config_entry_auth_failed": {
37+
"message": "Authentication failed. Please log in again."
38+
},
39+
"config_entry_not_ready": {
40+
"message": "Error while loading the integration."
41+
},
3042
"oauth2_implementation_unavailable": {
3143
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
3244
}

tests/components/senz/test_config_flow.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
from tests.test_util.aiohttp import AiohttpClientMocker
2323
from tests.typing import ClientSessionGenerator
2424

25+
REDIRECT_PATH = "/auth/external/callback"
26+
REDIRECT_URL = "https://example.com" + REDIRECT_PATH
27+
2528

2629
@pytest.mark.usefixtures("current_request_with_host")
2730
async def test_full_flow(
@@ -45,18 +48,18 @@ async def test_full_flow(
4548
hass,
4649
{
4750
"flow_id": result["flow_id"],
48-
"redirect_uri": "https://example.com/auth/external/callback",
51+
"redirect_uri": REDIRECT_URL,
4952
},
5053
)
5154

5255
assert result["url"] == (
5356
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
54-
"&redirect_uri=https://example.com/auth/external/callback"
57+
f"&redirect_uri={REDIRECT_URL}"
5558
f"&state={state}&scope=restapi+offline_access"
5659
)
5760

5861
client = await hass_client_no_auth()
59-
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
62+
resp = await client.get(f"{REDIRECT_PATH}?code=abcd&state={state}")
6063
assert resp.status == 200
6164
assert resp.headers["content-type"] == "text/html; charset=utf-8"
6265

@@ -96,18 +99,18 @@ async def test_duplicate_flow(
9699
hass,
97100
{
98101
"flow_id": result["flow_id"],
99-
"redirect_uri": "https://example.com/auth/external/callback",
102+
"redirect_uri": REDIRECT_URL,
100103
},
101104
)
102105

103106
assert result["url"] == (
104107
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
105-
"&redirect_uri=https://example.com/auth/external/callback"
108+
f"&redirect_uri={REDIRECT_URL}"
106109
f"&state={state}&scope=restapi+offline_access"
107110
)
108111

109112
client = await hass_client_no_auth()
110-
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
113+
resp = await client.get(f"{REDIRECT_PATH}?code=abcd&state={state}")
111114
assert resp.status == 200
112115
assert resp.headers["content-type"] == "text/html; charset=utf-8"
113116

@@ -126,3 +129,76 @@ async def test_duplicate_flow(
126129

127130
assert result2["type"] is FlowResultType.ABORT
128131
assert result2["reason"] == "already_configured"
132+
133+
134+
@pytest.mark.usefixtures("current_request_with_host")
135+
async def test_reauth_flow(
136+
hass: HomeAssistant,
137+
hass_client_no_auth: ClientSessionGenerator,
138+
aioclient_mock: AiohttpClientMocker,
139+
mock_config_entry: MockConfigEntry,
140+
access_token: str,
141+
expires_at: float,
142+
) -> None:
143+
"""Test reauth step with correct params."""
144+
145+
CURRENT_TOKEN = {
146+
"auth_implementation": DOMAIN,
147+
"token": {
148+
"access_token": access_token,
149+
"expires_in": 86399,
150+
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
151+
"token_type": "Bearer",
152+
"expires_at": expires_at,
153+
},
154+
}
155+
assert hass.config_entries.async_update_entry(
156+
mock_config_entry,
157+
data=CURRENT_TOKEN,
158+
)
159+
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
160+
161+
result = await mock_config_entry.start_reauth_flow(hass)
162+
163+
assert result["step_id"] == "reauth_confirm"
164+
165+
result = await hass.config_entries.flow.async_configure(
166+
result["flow_id"], user_input={}
167+
)
168+
assert result["step_id"] == "auth"
169+
170+
state = config_entry_oauth2_flow._encode_jwt(
171+
hass,
172+
{
173+
"flow_id": result["flow_id"],
174+
"redirect_uri": REDIRECT_URL,
175+
},
176+
)
177+
assert result["url"] == (
178+
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
179+
f"&redirect_uri={REDIRECT_URL}"
180+
f"&state={state}&scope=restapi+offline_access"
181+
)
182+
183+
client = await hass_client_no_auth()
184+
resp = await client.get(f"{REDIRECT_PATH}?code=abcd&state={state}")
185+
assert resp.status == 200
186+
assert resp.headers["content-type"] == "text/html; charset=utf-8"
187+
188+
aioclient_mock.post(
189+
TOKEN_ENDPOINT,
190+
json={
191+
"refresh_token": "updated-refresh-token",
192+
"access_token": access_token,
193+
"type": "Bearer",
194+
"expires_in": "60",
195+
},
196+
)
197+
198+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
199+
await hass.async_block_till_done()
200+
201+
assert result.get("type") is FlowResultType.ABORT
202+
assert result.get("reason") == "reauth_successful"
203+
204+
assert len(hass.config_entries.async_entries(DOMAIN)) == 1

tests/components/senz/test_init.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
"""Test init of senz integration."""
22

3+
from http import HTTPStatus
4+
import time
35
from unittest.mock import MagicMock, patch
46

7+
from aiosenz import TOKEN_ENDPOINT
8+
from httpx import HTTPStatusError, RequestError
9+
import pytest
10+
511
from homeassistant.components.senz.const import DOMAIN
612
from homeassistant.config_entries import ConfigEntryState
713
from homeassistant.core import HomeAssistant
@@ -13,6 +19,7 @@
1319
from .const import ENTRY_UNIQUE_ID
1420

1521
from tests.common import MockConfigEntry
22+
from tests.test_util.aiohttp import AiohttpClientMocker
1623

1724

1825
async def test_load_unload_entry(
@@ -78,3 +85,77 @@ async def test_migrate_config_entry(
7885
assert mock_entry_v1_1.version == 1
7986
assert mock_entry_v1_1.minor_version == 2
8087
assert mock_entry_v1_1.unique_id == ENTRY_UNIQUE_ID
88+
89+
90+
@pytest.mark.parametrize(
91+
("expires_at", "status", "expected_state"),
92+
[
93+
(
94+
time.time() - 3600,
95+
HTTPStatus.UNAUTHORIZED,
96+
ConfigEntryState.SETUP_ERROR,
97+
),
98+
(
99+
time.time() - 3600,
100+
HTTPStatus.INTERNAL_SERVER_ERROR,
101+
ConfigEntryState.SETUP_ERROR,
102+
),
103+
],
104+
ids=["unauthorized", "internal_server_error"],
105+
)
106+
async def test_expired_token_refresh_failure(
107+
hass: HomeAssistant,
108+
mock_config_entry: MockConfigEntry,
109+
aioclient_mock: AiohttpClientMocker,
110+
status: HTTPStatus,
111+
expected_state: ConfigEntryState,
112+
) -> None:
113+
"""Test failure while refreshing token with a transient error."""
114+
115+
aioclient_mock.clear_requests()
116+
aioclient_mock.post(
117+
TOKEN_ENDPOINT,
118+
status=status,
119+
)
120+
121+
await setup_integration(hass, mock_config_entry)
122+
123+
assert mock_config_entry.state is expected_state
124+
125+
126+
@pytest.mark.parametrize(
127+
("error", "expected_state"),
128+
[
129+
(
130+
HTTPStatusError(
131+
message="Exception",
132+
request=None,
133+
response=MagicMock(status_code=HTTPStatus.UNAUTHORIZED),
134+
),
135+
ConfigEntryState.SETUP_ERROR,
136+
),
137+
(
138+
HTTPStatusError(
139+
message="Exception",
140+
request=None,
141+
response=MagicMock(status_code=HTTPStatus.FORBIDDEN),
142+
),
143+
ConfigEntryState.SETUP_RETRY,
144+
),
145+
(RequestError("Exception"), ConfigEntryState.SETUP_RETRY),
146+
],
147+
ids=["unauthorized", "forbidden", "request_error"],
148+
)
149+
async def test_setup_errors(
150+
hass: HomeAssistant,
151+
mock_config_entry: MockConfigEntry,
152+
mock_senz_client: MagicMock,
153+
error: Exception,
154+
expected_state: ConfigEntryState,
155+
) -> None:
156+
"""Test setup failure due to unauthorized error."""
157+
mock_senz_client.get_account.side_effect = error
158+
159+
await setup_integration(hass, mock_config_entry)
160+
161+
assert mock_config_entry.state is expected_state

0 commit comments

Comments
 (0)