Skip to content

Commit 9f3a0e7

Browse files
hugo-britoCopilot
andcommitted
Make V02 authentication failures recoverable
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 570c817 commit 9f3a0e7

9 files changed

Lines changed: 210 additions & 26 deletions

File tree

custom_components/bestway/__init__.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime, timedelta
66
from logging import getLogger
77

8-
from aiohttp import ClientSession
8+
from aiohttp import ClientError, ClientSession
99
from homeassistant.config_entries import ConfigEntry
1010
from homeassistant.const import Platform
1111
from homeassistant.core import HomeAssistant
@@ -158,7 +158,11 @@ async def _async_setup_aws_iot(
158158
hass: HomeAssistant, entry: ConfigEntry, session: ClientSession
159159
) -> bool:
160160
"""Set up AWS IoT V02 backend."""
161-
from .aws_iot.api import AwsIotApi, AwsIotAuthException
161+
from .aws_iot.api import (
162+
AwsIotApi,
163+
AwsIotAuthException,
164+
AwsIotConnectionError,
165+
)
162166
from .aws_iot.websocket import AwsIotWebSocket
163167

164168
visitor_id = entry.data["visitor_id"]
@@ -193,7 +197,10 @@ async def _async_setup_aws_iot(
193197
hass.config_entries.async_update_entry(
194198
entry, data={**entry.data, "token": token}
195199
)
196-
api._token = token
200+
api.update_token(token)
201+
except (AwsIotConnectionError, TimeoutError, ClientError) as ex:
202+
_LOGGER.warning("AWS IoT authentication service unavailable: %s", ex)
203+
raise ConfigEntryNotReady from ex
197204
except AwsIotAuthException as ex:
198205
_LOGGER.error("AWS IoT authentication failed: %s", ex)
199206
raise ConfigEntryAuthFailed from ex
@@ -212,10 +219,12 @@ async def token_refresh_callback() -> str:
212219
new_token = await AwsIotApi.authenticate(
213220
session, visitor_id, location, api_base
214221
)
215-
api._token = new_token
222+
api.update_token(new_token)
216223
hass.config_entries.async_update_entry(
217224
entry, data={**entry.data, "token": new_token}
218225
)
226+
for websocket in coordinator.websockets:
227+
websocket.update_token(new_token)
219228
return new_token
220229

221230
ws = AwsIotWebSocket(

custom_components/bestway/aws_iot/api.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from time import time
1919
from typing import Any
2020

21-
from aiohttp import ClientSession
21+
from aiohttp import ClientError, ClientSession
2222

2323
from .encryption import encrypt_command_payload
2424
from ..bestway.model import (
@@ -34,7 +34,7 @@
3434
DEFAULT_API_BASE = "https://smarthub-eu.bestwaycorp.com" # EU endpoint
3535
APP_ID = "AhFLL54HnChhrxcl9ZUJL6QNfolTIB"
3636
APP_SECRET = "4ECvVs13enL5AiYSmscNjvlaisklQDz7vWPCCWXcEFjhWfTmLT"
37-
TIMEOUT = 10
37+
TIMEOUT = 20
3838

3939
# Regional API endpoints (from ServiceConfig.java)
4040
API_ENDPOINTS = {
@@ -53,6 +53,10 @@ class AwsIotAuthException(AwsIotException):
5353
"""Authentication error."""
5454

5555

56+
class AwsIotConnectionError(AwsIotException):
57+
"""Transient connection error."""
58+
59+
5660
class AwsIotApi:
5761
"""AWS IoT API client matching Gizwits BestwayApi interface.
5862
@@ -215,7 +219,8 @@ async def authenticate(
215219
Authentication token
216220
217221
Raises:
218-
AwsIotAuthException: If authentication fails
222+
AwsIotAuthException: If authentication is rejected
223+
AwsIotConnectionError: If the authentication service cannot be reached
219224
"""
220225
import random
221226
import string
@@ -271,20 +276,32 @@ async def authenticate(
271276
_LOGGER.debug("Sign in headers: %s", "sign" in headers)
272277
_LOGGER.debug("All header keys: %s", list(headers.keys()))
273278

274-
async with asyncio.timeout(TIMEOUT):
275-
async with session.post(
276-
url, headers=headers, json=payload, ssl=False
277-
) as resp:
278-
data = await resp.json()
279-
_LOGGER.debug("Auth response: %s", data)
280-
_LOGGER.debug("Response status: %s", resp.status)
281-
token = data.get("data", {}).get("token")
282-
283-
if not token:
284-
_LOGGER.error("No token in response. Full response: %s", data)
285-
raise AwsIotAuthException("No token in authentication response")
286-
287-
return str(token)
279+
try:
280+
async with asyncio.timeout(TIMEOUT):
281+
async with session.post(
282+
url, headers=headers, json=payload, ssl=False
283+
) as resp:
284+
data = await resp.json()
285+
_LOGGER.debug("Auth response: %s", data)
286+
_LOGGER.debug("Response status: %s", resp.status)
287+
288+
if resp.status in (401, 403):
289+
raise AwsIotAuthException("Authentication rejected")
290+
291+
token = data.get("data", {}).get("token")
292+
if not token:
293+
_LOGGER.error("No token in response. Full response: %s", data)
294+
raise AwsIotAuthException("No token in authentication response")
295+
296+
return str(token)
297+
except (TimeoutError, ClientError) as err:
298+
raise AwsIotConnectionError(
299+
"Unable to reach the authentication service"
300+
) from err
301+
302+
def update_token(self, token: str) -> None:
303+
"""Replace the token used by subsequent API requests."""
304+
self._token = token
288305

289306
@staticmethod
290307
async def bind_qr_code(

custom_components/bestway/aws_iot/websocket.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ def ws_url(self) -> str:
9393
)
9494
return endpoint
9595

96+
def update_token(self, token: str) -> None:
97+
"""Replace the token used by the next connection attempt."""
98+
self._token = token
99+
96100
async def connect(self) -> None:
97101
"""Connect to region-specific WebSocket endpoint.
98102

custom_components/bestway/config_flow.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2020
import voluptuous as vol
2121

22-
from .aws_iot.api import AwsIotAuthException
22+
from .aws_iot.api import (
23+
API_ENDPOINTS,
24+
AwsIotApi,
25+
AwsIotAuthException,
26+
AwsIotConnectionError,
27+
)
2328
from .bestway.api import (
2429
BestwayApi,
2530
BestwayIncorrectPasswordException,
@@ -137,6 +142,37 @@ async def async_step_user(
137142
else:
138143
return await self.async_step_aws_iot_auth()
139144

145+
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
146+
"""Refresh credentials for an existing AWS IoT entry."""
147+
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
148+
if entry is None or entry.data.get("backend") != BACKEND_AWS_IOT:
149+
return self.async_abort(reason="reauth_unsuccessful")
150+
151+
visitor_id = entry.data["visitor_id"]
152+
location = entry.data.get("location", "GB")
153+
api_base = entry.data.get("api_base")
154+
if not api_base:
155+
api_base = API_ENDPOINTS.get(
156+
entry.data.get("region", "EU"), API_ENDPOINTS["EU"]
157+
)
158+
159+
try:
160+
token = await AwsIotApi.authenticate(
161+
async_get_clientsession(self.hass),
162+
visitor_id,
163+
location,
164+
api_base,
165+
)
166+
except AwsIotConnectionError:
167+
return self.async_abort(reason="cannot_connect")
168+
except AwsIotAuthException:
169+
return self.async_abort(reason="reauth_unsuccessful")
170+
171+
return self.async_update_reload_and_abort(
172+
entry,
173+
data_updates={"token": token},
174+
)
175+
140176
async def async_step_gizwits_auth(
141177
self, user_input: dict[str, Any] | None = None
142178
) -> ConfigFlowResult:
@@ -239,8 +275,6 @@ async def async_step_aws_iot_auth(
239275
)
240276

241277
try:
242-
from .aws_iot.api import AwsIotApi, API_ENDPOINTS
243-
244278
session = async_get_clientsession(self.hass)
245279

246280
# Map region to API endpoint
@@ -340,6 +374,9 @@ async def async_step_aws_iot_auth(
340374
except AwsIotAuthException as auth_err:
341375
_LOGGER.error("AWS IoT authentication failed: %s", auth_err)
342376
errors["base"] = "auth_failed"
377+
except AwsIotConnectionError as connection_err:
378+
_LOGGER.error("AWS IoT connection failed: %s", connection_err)
379+
errors["base"] = "cannot_connect"
343380
except Exception: # pylint: disable=broad-except
344381
_LOGGER.exception("AWS IoT setup failed")
345382
errors["base"] = "unknown"

custom_components/bestway/translations/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
}
2828
}
2929
},
30+
"abort": {
31+
"cannot_connect": "Could not connect to the Bestway API. Home Assistant will retry setup.",
32+
"reauth_successful": "Authentication refreshed successfully.",
33+
"reauth_unsuccessful": "Authentication could not be refreshed."
34+
},
3035
"error": {
3136
"cannot_connect": "Could not connect to the Bestway API",
3237
"user_does_not_exist": "User account does not exist",

tests/test_aws_iot_api.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from custom_components.bestway.aws_iot.api import (
77
AwsIotApi,
88
AwsIotAuthException,
9+
AwsIotConnectionError,
910
)
1011

1112

@@ -19,6 +20,26 @@ def create_mock_response(status: int, json_data: dict):
1920
return response
2021

2122

23+
@pytest.mark.asyncio
24+
async def test_authenticate_wraps_timeout_as_connection_error(mock_session):
25+
"""Authentication timeouts are classified as transient connection errors."""
26+
mock_session.post = MagicMock(side_effect=TimeoutError)
27+
28+
with pytest.raises(AwsIotConnectionError):
29+
await AwsIotApi.authenticate(mock_session, "test_visitor")
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_authenticate_rejects_missing_token(mock_session):
34+
"""A successful response without a token is an authentication failure."""
35+
mock_session.post = MagicMock(
36+
return_value=create_mock_response(200, {"code": 1, "data": {}})
37+
)
38+
39+
with pytest.raises(AwsIotAuthException):
40+
await AwsIotApi.authenticate(mock_session, "test_visitor")
41+
42+
2243
@pytest.fixture
2344
def mock_session():
2445
"""Create mock aiohttp ClientSession."""

tests/test_aws_iot_websocket.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ async def test_region_fallback_to_eu(aws_websocket):
224224
assert "eu-central-1" in url
225225

226226

227+
def test_update_token_changes_next_connection_token(aws_websocket):
228+
"""Token updates are applied to subsequent WebSocket connections."""
229+
aws_websocket.update_token("fresh_token")
230+
231+
assert aws_websocket._token == "fresh_token"
232+
233+
227234
@pytest.mark.asyncio
228235
async def test_heartbeat_loop_sends_messages(aws_websocket):
229236
"""Test heartbeat loop sends JSON heartbeat and WebSocket ping."""

tests/test_config_flow.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from homeassistant import config_entries
88
from homeassistant.data_entry_flow import FlowResultType
99
import pytest
10+
from pytest_homeassistant_custom_component.common import MockConfigEntry
1011

1112
from custom_components.bestway.bestway.model import BestwayUserToken
1213
from custom_components.bestway.const import (
14+
BACKEND_AWS_IOT,
1315
CONF_API_ROOT,
1416
CONF_API_ROOT_EU,
1517
CONF_PASSWORD,
@@ -210,3 +212,39 @@ async def test_backend_selection_shows_both_options(hass):
210212
# Schema should have backend field with options
211213
schema_keys = list(result["data_schema"].schema.keys())
212214
assert any("backend" in str(key) for key in schema_keys)
215+
216+
217+
async def test_aws_iot_reauth_refreshes_token(hass):
218+
"""AWS IoT reauth uses the stored visitor ID without user input."""
219+
entry = MockConfigEntry(
220+
version=2,
221+
domain=DOMAIN,
222+
title="Bestway Spa",
223+
data={
224+
"backend": BACKEND_AWS_IOT,
225+
"visitor_id": "test_visitor",
226+
"token": "old_token",
227+
"region": "EU",
228+
"api_base": "https://example.test",
229+
},
230+
source=config_entries.SOURCE_USER,
231+
)
232+
entry.add_to_hass(hass)
233+
234+
with patch(
235+
"custom_components.bestway.config_flow.AwsIotApi.authenticate",
236+
return_value="new_token",
237+
) as authenticate:
238+
result = await hass.config_entries.flow.async_init(
239+
DOMAIN,
240+
context={
241+
"source": config_entries.SOURCE_REAUTH,
242+
"entry_id": entry.entry_id,
243+
},
244+
data=dict(entry.data),
245+
)
246+
247+
assert result["type"] is FlowResultType.ABORT
248+
assert result["reason"] == "reauth_successful"
249+
assert entry.data["token"] == "new_token"
250+
authenticate.assert_awaited_once()

0 commit comments

Comments
 (0)