From eea04558a92874cd1262a402bf431622fedb5b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 13 Aug 2025 13:21:28 +0200 Subject: [PATCH] Move alexa access token updates to new handler (#150466) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/cloud/alexa_config.py | 53 +++++++++-------- tests/components/cloud/test_alexa_config.py | 58 +++++++++++-------- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5bd40eb5b83..26fda1a405f 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -6,12 +6,16 @@ import asyncio from collections.abc import Callable from contextlib import suppress from datetime import datetime, timedelta -from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any import aiohttp -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import AlexaApiError, Cloud +from hass_nabucasa.alexa_api import ( + AlexaAccessTokenDetails, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) from yarl import URL from homeassistant.components import persistent_notification @@ -146,7 +150,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud_user = cloud_user self._prefs = prefs self._cloud = cloud - self._token = None + self._token: str | None = None self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None @@ -318,32 +322,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def async_get_access_token(self) -> str | None: """Get an access token.""" + details: AlexaAccessTokenDetails | None if self._token_valid is not None and self._token_valid > utcnow(): return self._token - resp = await cloud_api.async_alexa_access_token(self._cloud) - body = await resp.json() + try: + details = await self._cloud.alexa_api.access_token() + except AlexaApiNeedsRelinkError as exception: + if self.should_report_state: + persistent_notification.async_create( + self.hass, + ( + "There was an error reporting state to Alexa" + f" ({exception.reason}). Please re-link your Alexa skill via" + " the Alexa app to continue using it." + ), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise alexa_errors.RequireRelink from exception + except (AlexaApiNoTokenError, AlexaApiError) as exception: + raise alexa_errors.NoTokenAvailable from exception - if resp.status == HTTPStatus.BAD_REQUEST: - if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): - if self.should_report_state: - persistent_notification.async_create( - self.hass, - ( - "There was an error reporting state to Alexa" - f" ({body['reason']}). Please re-link your Alexa skill via" - " the Alexa app to continue using it." - ), - "Alexa state reporting disabled", - "cloud_alexa_report", - ) - raise alexa_errors.RequireRelink - - raise alexa_errors.NoTokenAvailable - - self._token = body["access_token"] - self._endpoint = body["event_endpoint"] - self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + self._token = details["access_token"] + self._endpoint = details["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=details["expires_in"]) return self._token async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index ef7a99453f0..7fc8c73785b 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -3,6 +3,11 @@ import contextlib from unittest.mock import AsyncMock, Mock, patch +from hass_nabucasa.alexa_api import ( + AlexaApiError, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) import pytest from homeassistant.components.alexa import errors @@ -195,30 +200,40 @@ async def test_alexa_config_invalidate_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 assert conf._token_valid is not None conf.async_invalidate_access_token() assert conf._token_valid is None token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 2 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 2 @pytest.mark.parametrize( - ("reject_reason", "expected_exception"), + ("lib_exception", "expected_exception"), [ - ("RefreshTokenNotFound", errors.RequireRelink), - ("UnknownRegion", errors.RequireRelink), - ("OtherReason", errors.NoTokenAvailable), + (AlexaApiNeedsRelinkError("Needs relink"), errors.RequireRelink), + (AlexaApiNeedsRelinkError("UnknownRegion"), errors.RequireRelink), + (AlexaApiNoTokenError("OtherReason"), errors.NoTokenAvailable), + (AlexaApiError("OtherReason"), errors.NoTokenAvailable), ], ) async def test_alexa_config_fail_refresh_token( @@ -226,7 +241,7 @@ async def test_alexa_config_fail_refresh_token( cloud_prefs: CloudPreferences, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, - reject_reason: str, + lib_exception: Exception, expected_exception: type[Exception], ) -> None: """Test Alexa config failing to refresh token.""" @@ -259,6 +274,15 @@ async def test_alexa_config_fail_refresh_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) await conf.async_initialize() @@ -284,12 +308,7 @@ async def test_alexa_config_fail_refresh_token( # Invalidate the token and try to fetch another conf.async_invalidate_access_token() - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={"reason": reject_reason}, - status=400, - ) + conf._cloud.alexa_api.access_token.side_effect = lib_exception # Change states to trigger event listener hass.states.async_set(entity_entry.entity_id, "off") @@ -310,15 +329,8 @@ async def test_alexa_config_fail_refresh_token( # Simulate we're again authorized and token update succeeds # State reporting should now be re-enabled for Alexa - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={ - "access_token": "mock-token", - "event_endpoint": "http://example.com/alexa_endpoint", - "expires_in": 30, - }, - ) + conf._cloud.alexa_api.access_token.side_effect = None + await conf.set_authorized(True) assert cloud_prefs.alexa_report_state is True assert conf.should_report_state is True