Move alexa access token updates to new handler (#150466)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Joakim Sørensen
2025-08-13 13:21:28 +02:00
committed by GitHub
parent 5ad2a27918
commit eea04558a9
2 changed files with 63 additions and 48 deletions

View File

@@ -6,12 +6,16 @@ import asyncio
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http import HTTPStatus
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import aiohttp 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 yarl import URL
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
@@ -146,7 +150,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._cloud_user = cloud_user self._cloud_user = cloud_user
self._prefs = prefs self._prefs = prefs
self._cloud = cloud self._cloud = cloud
self._token = None self._token: str | None = None
self._token_valid: datetime | None = None self._token_valid: datetime | None = None
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None 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: async def async_get_access_token(self) -> str | None:
"""Get an access token.""" """Get an access token."""
details: AlexaAccessTokenDetails | None
if self._token_valid is not None and self._token_valid > utcnow(): if self._token_valid is not None and self._token_valid > utcnow():
return self._token return self._token
resp = await cloud_api.async_alexa_access_token(self._cloud) try:
body = await resp.json() 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: self._token = details["access_token"]
if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): self._endpoint = details["event_endpoint"]
if self.should_report_state: self._token_valid = utcnow() + timedelta(seconds=details["expires_in"])
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"])
return self._token return self._token
async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:

View File

@@ -3,6 +3,11 @@
import contextlib import contextlib
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from hass_nabucasa.alexa_api import (
AlexaApiError,
AlexaApiNeedsRelinkError,
AlexaApiNoTokenError,
)
import pytest import pytest
from homeassistant.components.alexa import errors from homeassistant.components.alexa import errors
@@ -195,30 +200,40 @@ async def test_alexa_config_invalidate_token(
servicehandlers_server="example", servicehandlers_server="example",
auth=Mock(async_check_token=AsyncMock()), auth=Mock(async_check_token=AsyncMock()),
websession=async_get_clientsession(hass), 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() token = await conf.async_get_access_token()
assert token == "mock-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() token = await conf.async_get_access_token()
assert token == "mock-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 assert conf._token_valid is not None
conf.async_invalidate_access_token() conf.async_invalidate_access_token()
assert conf._token_valid is None assert conf._token_valid is None
token = await conf.async_get_access_token() token = await conf.async_get_access_token()
assert token == "mock-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( @pytest.mark.parametrize(
("reject_reason", "expected_exception"), ("lib_exception", "expected_exception"),
[ [
("RefreshTokenNotFound", errors.RequireRelink), (AlexaApiNeedsRelinkError("Needs relink"), errors.RequireRelink),
("UnknownRegion", errors.RequireRelink), (AlexaApiNeedsRelinkError("UnknownRegion"), errors.RequireRelink),
("OtherReason", errors.NoTokenAvailable), (AlexaApiNoTokenError("OtherReason"), errors.NoTokenAvailable),
(AlexaApiError("OtherReason"), errors.NoTokenAvailable),
], ],
) )
async def test_alexa_config_fail_refresh_token( async def test_alexa_config_fail_refresh_token(
@@ -226,7 +241,7 @@ async def test_alexa_config_fail_refresh_token(
cloud_prefs: CloudPreferences, cloud_prefs: CloudPreferences,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
reject_reason: str, lib_exception: Exception,
expected_exception: type[Exception], expected_exception: type[Exception],
) -> None: ) -> None:
"""Test Alexa config failing to refresh token.""" """Test Alexa config failing to refresh token."""
@@ -259,6 +274,15 @@ async def test_alexa_config_fail_refresh_token(
servicehandlers_server="example", servicehandlers_server="example",
auth=Mock(async_check_token=AsyncMock()), auth=Mock(async_check_token=AsyncMock()),
websession=async_get_clientsession(hass), 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() await conf.async_initialize()
@@ -284,12 +308,7 @@ async def test_alexa_config_fail_refresh_token(
# Invalidate the token and try to fetch another # Invalidate the token and try to fetch another
conf.async_invalidate_access_token() conf.async_invalidate_access_token()
aioclient_mock.clear_requests() conf._cloud.alexa_api.access_token.side_effect = lib_exception
aioclient_mock.post(
"https://example/alexa/access_token",
json={"reason": reject_reason},
status=400,
)
# Change states to trigger event listener # Change states to trigger event listener
hass.states.async_set(entity_entry.entity_id, "off") 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 # Simulate we're again authorized and token update succeeds
# State reporting should now be re-enabled for Alexa # State reporting should now be re-enabled for Alexa
aioclient_mock.clear_requests() conf._cloud.alexa_api.access_token.side_effect = None
aioclient_mock.post(
"https://example/alexa/access_token",
json={
"access_token": "mock-token",
"event_endpoint": "http://example.com/alexa_endpoint",
"expires_in": 30,
},
)
await conf.set_authorized(True) await conf.set_authorized(True)
assert cloud_prefs.alexa_report_state is True assert cloud_prefs.alexa_report_state is True
assert conf.should_report_state is True assert conf.should_report_state is True