Skip to content

Commit ec9ced0

Browse files
hugo-britoCopilot
andcommitted
Harden V02 runtime authentication recovery
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9f3a0e7 commit ec9ced0

4 files changed

Lines changed: 170 additions & 41 deletions

File tree

custom_components/bestway/__init__.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,21 @@ async def _async_setup_aws_iot(
207207

208208
# Initialize coordinator
209209
coordinator = BestwayUpdateCoordinator(hass, entry, api)
210+
211+
def token_updated(new_token: str) -> None:
212+
nonlocal token
213+
token = new_token
214+
hass.config_entries.async_update_entry(
215+
entry, data={**entry.data, "token": new_token}
216+
)
217+
for websocket in coordinator.websockets:
218+
websocket.update_token(new_token)
219+
220+
api.set_token_update_callback(token_updated)
210221
await coordinator.async_config_entry_first_refresh()
211222

212223
# Initialize per-device WebSockets
213-
websockets = []
224+
websockets = coordinator.websockets
214225
if api.devices:
215226
for device_id, device in api.devices.items():
216227
try:
@@ -220,11 +231,6 @@ async def token_refresh_callback() -> str:
220231
session, visitor_id, location, api_base
221232
)
222233
api.update_token(new_token)
223-
hass.config_entries.async_update_entry(
224-
entry, data={**entry.data, "token": new_token}
225-
)
226-
for websocket in coordinator.websockets:
227-
websocket.update_token(new_token)
228234
return new_token
229235

230236
ws = AwsIotWebSocket(
@@ -257,8 +263,6 @@ async def token_refresh_callback() -> str:
257263
_LOGGER.warning("No devices found, WebSocket not initialized")
258264

259265
# Store WebSockets list on coordinator
260-
coordinator.websockets = websockets
261-
262266
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
263267
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
264268

custom_components/bestway/aws_iot/api.py

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import hashlib
1616
import logging
1717
import secrets
18+
from collections.abc import Callable
1819
from time import time
1920
from typing import Any
2021

@@ -98,6 +99,7 @@ def __init__(
9899

99100
# State cache (matches Gizwits interface)
100101
self._state_cache: dict[str, BestwayDeviceStatus] = {}
102+
self._token_update_callback: Callable[[str], None] | None = None
101103

102104
@staticmethod
103105
def generate_visitor_id() -> str:
@@ -281,13 +283,13 @@ async def authenticate(
281283
async with session.post(
282284
url, headers=headers, json=payload, ssl=False
283285
) as resp:
286+
if resp.status in (401, 403):
287+
raise AwsIotAuthException("Authentication rejected")
288+
284289
data = await resp.json()
285290
_LOGGER.debug("Auth response: %s", data)
286291
_LOGGER.debug("Response status: %s", resp.status)
287292

288-
if resp.status in (401, 403):
289-
raise AwsIotAuthException("Authentication rejected")
290-
291293
token = data.get("data", {}).get("token")
292294
if not token:
293295
_LOGGER.error("No token in response. Full response: %s", data)
@@ -302,6 +304,12 @@ async def authenticate(
302304
def update_token(self, token: str) -> None:
303305
"""Replace the token used by subsequent API requests."""
304306
self._token = token
307+
if self._token_update_callback is not None:
308+
self._token_update_callback(token)
309+
310+
def set_token_update_callback(self, callback: Callable[[str], None] | None) -> None:
311+
"""Set a callback for persisting and propagating refreshed tokens."""
312+
self._token_update_callback = callback
305313

306314
@staticmethod
307315
async def bind_qr_code(
@@ -420,15 +428,14 @@ async def _do_get(self, path: str) -> dict[str, Any]:
420428

421429
async with asyncio.timeout(TIMEOUT):
422430
async with self._session.get(url, headers=headers, ssl=False) as response:
423-
data = await response.json()
424-
425431
# Check for errors
426-
if response.status in (400, 401):
432+
if response.status in (400, 401, 403):
427433
raise AwsIotAuthException("Token expired or invalid")
428434

429435
if response.status != 200:
430436
raise AwsIotException(f"API error: {response.status}")
431437

438+
data = await response.json()
432439
return dict(data)
433440

434441
async def _do_post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
@@ -454,19 +461,17 @@ async def _do_post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
454461
async with self._session.post(
455462
url, headers=headers, json=data, ssl=False
456463
) as response:
457-
result = await response.json()
458-
459-
_LOGGER.debug(
460-
"POST %s response (status=%d): %s", path, response.status, result
461-
)
462-
463464
# Check for errors
464-
if response.status in (400, 401):
465+
if response.status in (400, 401, 403):
465466
raise AwsIotAuthException("Token expired or invalid")
466467

467468
if response.status != 200:
468469
raise AwsIotException(f"API error: {response.status}")
469470

471+
result = await response.json()
472+
_LOGGER.debug(
473+
"POST %s response (status=%d): %s", path, response.status, result
474+
)
470475
return dict(result)
471476

472477
async def refresh_bindings(self) -> None:
@@ -586,23 +591,10 @@ async def refresh_bindings(self) -> None:
586591

587592
self.devices[device_id] = device
588593

589-
async def fetch_data(self) -> Any: # Returns BestwayApiResults
590-
"""Fetch latest state for all devices.
591-
592-
Implements the same interface as Gizwits BestwayApi.fetch_data().
593-
594-
For each device:
595-
1. POST /api/device/thing_shadow/ with device_id + product_id
596-
2. Parse shadow.state.reported or shadow.state.desired
597-
3. Return raw AWS field names (water_temperature, temperature_setting, etc.)
598-
4. Store in state cache
599-
600-
Returns:
601-
BestwayApiResults with devices dict
602-
"""
603-
# Import here to avoid circular dependency
604-
from ..bestway.api import BestwayApiResults
605-
594+
async def _poll_all_devices(self) -> tuple[int, bool]:
595+
"""Poll every device once and return success and auth-failure status."""
596+
refreshed = 0
597+
auth_failed = False
606598
for device_id in self.devices:
607599
try:
608600
# Get device metadata
@@ -656,14 +648,22 @@ async def fetch_data(self) -> Any: # Returns BestwayApiResults
656648
self._state_cache[device_id] = BestwayDeviceStatus(
657649
timestamp=int(time()), attrs=mapped
658650
)
651+
refreshed += 1
659652

660653
_LOGGER.debug(
661654
"Fetched state for device %s: %d fields",
662655
device_id[:12],
663656
len(mapped),
664657
)
665658

666-
except Exception as err:
659+
except AwsIotAuthException as err:
660+
auth_failed = True
661+
_LOGGER.warning(
662+
"Authentication failure fetching device %s: %s",
663+
device_id[:12],
664+
err,
665+
)
666+
except Exception as err: # pylint: disable=broad-except
667667
_LOGGER.warning(
668668
"Failed to fetch state for device %s: %s", device_id[:12], err
669669
)
@@ -673,6 +673,36 @@ async def fetch_data(self) -> Any: # Returns BestwayApiResults
673673
timestamp=int(time()), attrs={}
674674
)
675675

676+
return refreshed, auth_failed
677+
678+
async def fetch_data(self) -> Any: # Returns BestwayApiResults
679+
"""Fetch state, refreshing an expired token once before failing."""
680+
from homeassistant.exceptions import ConfigEntryAuthFailed
681+
from homeassistant.helpers.update_coordinator import UpdateFailed
682+
683+
from ..bestway.api import BestwayApiResults
684+
685+
refreshed, auth_failed = await self._poll_all_devices()
686+
687+
if self.devices and refreshed == 0 and auth_failed:
688+
_LOGGER.info("Re-authenticating after auth failure during poll")
689+
try:
690+
token = await self.authenticate(
691+
self._session, self._visitor_id, self._location, self._api_base
692+
)
693+
except AwsIotAuthException as err:
694+
raise ConfigEntryAuthFailed from err
695+
except AwsIotConnectionError as err:
696+
raise UpdateFailed(
697+
"Unable to reach Bestway authentication service"
698+
) from err
699+
700+
self.update_token(token)
701+
refreshed, _ = await self._poll_all_devices()
702+
703+
if self.devices and refreshed == 0:
704+
raise UpdateFailed("Unable to refresh any Bestway device state")
705+
676706
return BestwayApiResults(devices=self._state_cache)
677707

678708
async def set_device_state(

custom_components/bestway/translations/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
}
2929
},
3030
"abort": {
31-
"cannot_connect": "Could not connect to the Bestway API. Home Assistant will retry setup.",
31+
"cannot_connect": "Could not connect to the Bestway API. Try reloading the integration.",
3232
"reauth_successful": "Authentication refreshed successfully.",
3333
"reauth_unsuccessful": "Authentication could not be refreshed."
3434
},

tests/test_aws_iot_api.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for AWS IoT API client."""
22

3-
from unittest.mock import AsyncMock, MagicMock
3+
from unittest.mock import AsyncMock, MagicMock, patch
44
import pytest
55

66
from custom_components.bestway.aws_iot.api import (
@@ -40,6 +40,19 @@ async def test_authenticate_rejects_missing_token(mock_session):
4040
await AwsIotApi.authenticate(mock_session, "test_visitor")
4141

4242

43+
@pytest.mark.asyncio
44+
async def test_authenticate_checks_rejection_before_parsing_body(mock_session):
45+
"""A non-JSON 401 remains an authentication failure."""
46+
response = create_mock_response(401, {})
47+
response.json.side_effect = Exception("not JSON")
48+
mock_session.post = MagicMock(return_value=response)
49+
50+
with pytest.raises(AwsIotAuthException):
51+
await AwsIotApi.authenticate(mock_session, "test_visitor")
52+
53+
response.json.assert_not_awaited()
54+
55+
4356
@pytest.fixture
4457
def mock_session():
4558
"""Create mock aiohttp ClientSession."""
@@ -323,6 +336,75 @@ async def test_fetch_data_returns_results(aws_api, mock_session):
323336
assert status.attrs["Tnow"] == 36
324337

325338

339+
def _make_aws_device(device_id="device1"):
340+
"""Build a real AWS IoT device for polling tests."""
341+
from custom_components.bestway.bestway.model import BestwayDevice
342+
343+
return BestwayDevice(
344+
protocol_version=2,
345+
device_id=device_id,
346+
product_name="AIRJET",
347+
alias="Test Spa",
348+
mcu_soft_version="unknown",
349+
mcu_hard_version="unknown",
350+
wifi_soft_version="unknown",
351+
wifi_hard_version="unknown",
352+
is_online=True,
353+
backend="aws_iot",
354+
product_id="T53NN8",
355+
)
356+
357+
358+
@pytest.mark.asyncio
359+
async def test_fetch_data_reauthenticates_and_propagates_token(aws_api):
360+
"""A poll auth failure refreshes once and propagates the new token."""
361+
aws_api.devices = {"device1": _make_aws_device()}
362+
shadow = {"code": 0, "data": {"state": {"reported": {"power_state": 1}}}}
363+
aws_api._do_post = AsyncMock(side_effect=[AwsIotAuthException("expired"), shadow])
364+
token_updated = MagicMock()
365+
aws_api.set_token_update_callback(token_updated)
366+
367+
with patch.object(
368+
AwsIotApi, "authenticate", new=AsyncMock(return_value="fresh_token")
369+
):
370+
results = await aws_api.fetch_data()
371+
372+
assert results.devices["device1"].attrs["power"] is True
373+
assert aws_api._token == "fresh_token"
374+
token_updated.assert_called_once_with("fresh_token")
375+
376+
377+
@pytest.mark.asyncio
378+
async def test_fetch_data_raises_auth_failed_when_reauth_rejected(aws_api):
379+
"""A rejected runtime reauth starts Home Assistant's reauth handling."""
380+
from homeassistant.exceptions import ConfigEntryAuthFailed
381+
382+
aws_api.devices = {"device1": _make_aws_device()}
383+
aws_api._do_post = AsyncMock(side_effect=AwsIotAuthException("expired"))
384+
385+
with (
386+
patch.object(
387+
AwsIotApi,
388+
"authenticate",
389+
new=AsyncMock(side_effect=AwsIotAuthException("rejected")),
390+
),
391+
pytest.raises(ConfigEntryAuthFailed),
392+
):
393+
await aws_api.fetch_data()
394+
395+
396+
@pytest.mark.asyncio
397+
async def test_fetch_data_raises_update_failed_on_total_connection_failure(aws_api):
398+
"""A total poll failure makes entities unavailable instead of stale."""
399+
from homeassistant.helpers.update_coordinator import UpdateFailed
400+
401+
aws_api.devices = {"device1": _make_aws_device()}
402+
aws_api._do_post = AsyncMock(side_effect=ConnectionError("offline"))
403+
404+
with pytest.raises(UpdateFailed):
405+
await aws_api.fetch_data()
406+
407+
326408
@pytest.mark.asyncio
327409
async def test_set_device_state_sends_command(aws_api, mock_session):
328410
"""Test control command sends encrypted payload."""
@@ -366,3 +448,16 @@ async def test_do_get_handles_401(aws_api, mock_session):
366448

367449
with pytest.raises(AwsIotAuthException):
368450
await aws_api._do_get("/test")
451+
452+
453+
@pytest.mark.asyncio
454+
async def test_do_post_checks_auth_status_before_parsing_body(aws_api, mock_session):
455+
"""A non-JSON auth rejection still triggers runtime reauthentication."""
456+
response = create_mock_response(401, {})
457+
response.json.side_effect = Exception("not JSON")
458+
mock_session.post = MagicMock(return_value=response)
459+
460+
with pytest.raises(AwsIotAuthException):
461+
await aws_api._do_post("/test", {})
462+
463+
response.json.assert_not_awaited()

0 commit comments

Comments
 (0)