From 043465e42f6cb7da3b228731159cd08ef2a6366b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 30 Dec 2025 01:40:45 +1000 Subject: [PATCH] Replace access token authentication with OAuth2 in Teslemetry (#158905) Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/__init__.py | 68 ++- .../teslemetry/application_credentials.py | 30 ++ .../components/teslemetry/config_flow.py | 132 +++--- homeassistant/components/teslemetry/const.py | 5 + .../components/teslemetry/manifest.json | 1 + homeassistant/components/teslemetry/oauth.py | 50 ++ .../components/teslemetry/strings.json | 15 +- .../generated/application_credentials.py | 1 + tests/components/teslemetry/__init__.py | 28 +- tests/components/teslemetry/conftest.py | 11 +- tests/components/teslemetry/const.py | 9 +- .../components/teslemetry/test_config_flow.py | 426 ++++++++++-------- tests/components/teslemetry/test_init.py | 157 ++++++- 13 files changed, 660 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/teslemetry/application_credentials.py create mode 100644 homeassistant/components/teslemetry/oauth.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index af4ce26a0cc..5513e2b625c 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable from typing import Final +from aiohttp import ClientResponseError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, @@ -14,16 +15,24 @@ from tesla_fleet_api.exceptions import ( from tesla_fleet_api.teslemetry import Teslemetry from teslemetry_stream import TeslemetryStream +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER +from .const import CLIENT_ID, DOMAIN, LOGGER from .coordinator import ( TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, @@ -56,6 +65,11 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Telemetry integration.""" + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, "", name="Teslemetry"), + ) async_setup_services(hass) return True @@ -63,13 +77,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: """Set up Teslemetry config.""" - access_token = entry.data[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) + + async def _get_access_token() -> str: + try: + await oauth_session.async_ensure_token_valid() + except ClientResponseError as e: + if e.status == 401: + raise ConfigEntryAuthFailed from e + raise ConfigEntryNotReady from e + token: str = oauth_session.token[CONF_ACCESS_TOKEN] + return token + # Create API connection teslemetry = Teslemetry( session=session, - access_token=access_token, + access_token=_get_access_token, ) try: calls = await asyncio.gather( @@ -125,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - if not stream: stream = TeslemetryStream( session, - access_token, + _get_access_token, server=f"{region.lower()}.teslemetry.com", parse_timestamp=True, manual=True, @@ -276,23 +302,29 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: TeslemetryConfigEntry ) -> bool: """Migrate config entry.""" - if config_entry.version > 1: + if config_entry.version > 2: + # This means the user has downgraded from a future version return False - if config_entry.version == 1 and config_entry.minor_version < 2: - # Add unique_id to existing entry - teslemetry = Teslemetry( - session=async_get_clientsession(hass), - access_token=config_entry.data[CONF_ACCESS_TOKEN], - ) - try: - metadata = await teslemetry.metadata() - except TeslaFleetError as e: - LOGGER.error(e.message) - return False + if config_entry.version == 1: + access_token = config_entry.data[CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) - hass.config_entries.async_update_entry( - config_entry, unique_id=metadata["uid"], version=1, minor_version=2 + # Convert legacy access token to OAuth tokens using migrate endpoint + try: + data = await Teslemetry(session, access_token).migrate_to_oauth( + CLIENT_ID, access_token, hass.config.location_name + ) + except ClientResponseError as e: + raise ConfigEntryAuthFailed from e + + # Add auth_implementation for OAuth2 flow compatibility + data["auth_implementation"] = DOMAIN + + return hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, ) return True diff --git a/homeassistant/components/teslemetry/application_credentials.py b/homeassistant/components/teslemetry/application_credentials.py new file mode 100644 index 00000000000..ca564bee484 --- /dev/null +++ b/homeassistant/components/teslemetry/application_credentials.py @@ -0,0 +1,30 @@ +"""Application Credentials platform the Teslemetry integration.""" + +from homeassistant.components.application_credentials import ( + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import AUTHORIZE_URL, TOKEN_URL +from .oauth import TeslemetryImplementation + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=AUTHORIZE_URL, + token_url=TOKEN_URL, + ) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: + """Return auth implementation.""" + return TeslemetryImplementation( + hass, + auth_domain, + credential.client_id, + ) diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index a25a98d6c68..cdcfaaa2289 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aiohttp import ClientConnectionError @@ -12,68 +13,97 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) from tesla_fleet_api.teslemetry import Teslemetry -import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER - -TESLEMETRY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) -DESCRIPTION_PLACEHOLDERS = { - "name": "Teslemetry", - "short_url": "teslemetry.com/console", - "url": "[teslemetry.com/console](https://teslemetry.com/console)", -} +from .const import CLIENT_ID, DOMAIN, LOGGER -class TeslemetryConfigFlow(ConfigFlow, domain=DOMAIN): - """Config Teslemetry API connection.""" +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Teslemetry OAuth2 authentication.""" - VERSION = 1 - MINOR_VERSION = 2 + DOMAIN = DOMAIN + VERSION = 2 + + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.data: dict[str, Any] = {} + self.uid: str | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return LOGGER + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow start.""" + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential(CLIENT_ID, "", name="Teslemetry"), + ) + return await super().async_step_user() + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle OAuth completion and create config entry.""" + self.data = data + + # Test the connection with the OAuth token + errors = await self.async_test_connection(data) + if errors: + return self.async_abort(reason="oauth_error") + + await self.async_set_unique_id(self.uid) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Teslemetry", + data=data, + ) + + async def async_test_connection(self, token_data: dict[str, Any]) -> dict[str, str]: + """Test the connection with OAuth token.""" + access_token = token_data["token"]["access_token"] - async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]: - """Reusable Auth Helper.""" teslemetry = Teslemetry( session=async_get_clientsession(self.hass), - access_token=user_input[CONF_ACCESS_TOKEN], + access_token=access_token, ) + try: metadata = await teslemetry.metadata() except InvalidToken: - return {CONF_ACCESS_TOKEN: "invalid_access_token"} + return {"base": "invalid_access_token"} except SubscriptionRequired: return {"base": "subscription_required"} except ClientConnectionError: return {"base": "cannot_connect"} except TeslaFleetError as e: - LOGGER.error(e) + LOGGER.error("Teslemetry API error: %s", e) return {"base": "unknown"} - await self.async_set_unique_id(metadata["uid"]) + self.uid = metadata["uid"] return {} - async def async_step_user( - self, user_input: Mapping[str, Any] | None = None - ) -> ConfigFlowResult: - """Get configuration from the user.""" - errors: dict[str, str] = {} - if user_input and not (errors := await self.async_auth(user_input)): - self._abort_if_unique_id_configured() - return self.async_create_entry( - title="Teslemetry", - data=user_input, - ) - - return self.async_show_form( - step_id="user", - data_schema=TESLEMETRY_SCHEMA, - description_placeholders=DESCRIPTION_PLACEHOLDERS, - errors=errors, - ) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -81,21 +111,13 @@ class TeslemetryConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, user_input: Mapping[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle users reauth credentials.""" - - errors: dict[str, str] = {} - - if user_input and not (errors := await self.async_auth(user_input)): - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data=user_input, + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"name": "Teslemetry"}, ) - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=DESCRIPTION_PLACEHOLDERS, - data_schema=TESLEMETRY_SCHEMA, - errors=errors, - ) + return await super().async_step_user() diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index ebda486aedf..a66f2dfcae8 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -9,6 +9,11 @@ DOMAIN = "teslemetry" LOGGER = logging.getLogger(__package__) +# OAuth +AUTHORIZE_URL = "https://teslemetry.com/connect" +TOKEN_URL = "https://api.teslemetry.com/oauth/token" +CLIENT_ID = "homeassistant" + ENERGY_HISTORY_FIELDS = [ "solar_energy_exported", "generator_energy_exported", diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 54465788d55..dc942335304 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -3,6 +3,7 @@ "name": "Teslemetry", "codeowners": ["@Bre77"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/teslemetry", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/teslemetry/oauth.py b/homeassistant/components/teslemetry/oauth.py new file mode 100644 index 00000000000..f96a3c277a9 --- /dev/null +++ b/homeassistant/components/teslemetry/oauth.py @@ -0,0 +1,50 @@ +"""Provide oauth implementations for the Teslemetry integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import AUTHORIZE_URL, TOKEN_URL + + +class TeslemetryImplementation( + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce +): + """Teslemetry OAuth2 implementation.""" + + def __init__(self, hass: HomeAssistant, domain: str, client_id: str) -> None: + """Initialize OAuth2 implementation.""" + + super().__init__( + hass, + domain, + client_id, + AUTHORIZE_URL, + TOKEN_URL, + ) + + @property + def name(self) -> str: + """Name of the implementation.""" + return "Teslemetry OAuth2" + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + data: dict = { + "name": self.hass.config.location_name, + } + data.update(super().extra_authorize_data) + return data + + @property + def extra_token_resolve_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the token resolve request.""" + data: dict = { + "name": self.hass.config.location_name, + } + data.update(super().extra_token_resolve_data) + return data diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 53bb56526e5..1256c1c28c9 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -28,6 +28,10 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_account_mismatch": "The reauthentication account does not match the original account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, @@ -39,17 +43,8 @@ }, "step": { "reauth_confirm": { - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - }, - "description": "The {name} integration needs to re-authenticate your account, please enter an access token from {url}", + "description": "The {name} integration needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]" - }, - "user": { - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - }, - "description": "Enter an access token from {url}." } } }, diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 0b0663d2183..f97e0e05e33 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -38,6 +38,7 @@ APPLICATION_CREDENTIALS = [ "smartthings", "spotify", "tesla_fleet", + "teslemetry", "twitch", "volvo", "watts", diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index 59727926f03..15f88240acd 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -1,5 +1,6 @@ """Tests for the Teslemetry integration.""" +import time from unittest.mock import patch from syrupy.assertion import SnapshotAssertion @@ -9,19 +10,34 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import CONFIG - from tests.common import MockConfigEntry +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + + return MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="abc-123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "expires_at": int(time.time()) + 3600, + }, + }, + ) + + async def setup_platform( - hass: HomeAssistant, platforms: list[Platform] | None = None + hass: HomeAssistant, + platforms: list[Platform] | None = None, ) -> MockConfigEntry: """Set up the Teslemetry platform.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, data=CONFIG, minor_version=2, unique_id="abc-123" - ) + mock_entry = mock_config_entry() mock_entry.add_to_hass(hass) if platforms is None: diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index ffcc74d5587..0d0e7d583cf 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for Teslemetry.""" +"""Test fixtures for Teslemetry component.""" from __future__ import annotations @@ -22,6 +22,15 @@ from .const import ( ) +@pytest.fixture +def mock_setup_entry(): + """Mock Teslemetry async_setup_entry method.""" + with patch( + "homeassistant.components.teslemetry.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + yield mock_async_setup_entry + + @pytest.fixture(autouse=True) def mock_metadata(): """Mock Tesla Fleet Api metadata method.""" diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 7b671bbeaaa..80c423190cc 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -5,7 +5,8 @@ from homeassistant.const import CONF_ACCESS_TOKEN from tests.common import load_json_object_fixture -CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +UNIQUE_ID = "abc-123" +CONFIG_V1 = {CONF_ACCESS_TOKEN: "abc-123"} WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} @@ -37,7 +38,7 @@ COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERR RESPONSE_OK = {"response": {}, "error": None} METADATA = { - "uid": "abc-123", + "uid": UNIQUE_ID, "region": "NA", "scopes": [ "openid", @@ -63,7 +64,7 @@ METADATA = { }, } METADATA_LEGACY = { - "uid": "abc-123", + "uid": UNIQUE_ID, "region": "NA", "scopes": [ "openid", @@ -89,7 +90,7 @@ METADATA_LEGACY = { }, } METADATA_NOSCOPE = { - "uid": "abc-123", + "uid": UNIQUE_ID, "region": "NA", "scopes": ["openid", "offline_access", "vehicle_device_data"], "vehicles": { diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index aeee3a620d4..7659c0ab6bc 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Teslemetry config flow.""" +import time from unittest.mock import AsyncMock, patch +from urllib.parse import parse_qs, urlparse from aiohttp import ClientConnectionError import pytest @@ -10,222 +12,294 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) -from homeassistant import config_entries -from homeassistant.components.teslemetry.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.teslemetry.const import ( + AUTHORIZE_URL, + CLIENT_ID, + DOMAIN, + TOKEN_URL, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow -from .const import CONFIG, METADATA +from . import setup_platform +from .const import CONFIG_V1, UNIQUE_ID from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -BAD_CONFIG = {CONF_ACCESS_TOKEN: "bad_access_token"} +REDIRECT = "https://example.com/auth/external/callback" -async def test_form( +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_setup_entry") +async def test_oauth_flow( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test we get the form.""" - result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert result1["type"] is FlowResultType.FORM - assert not result1["errors"] - with patch( - "homeassistant.components.teslemetry.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - CONFIG, - ) - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == CONFIG + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + assert result["url"].startswith(AUTHORIZE_URL) + parsed_url = urlparse(result["url"]) + parsed_query = parse_qs(parsed_url.query) + assert parsed_query["response_type"][0] == "code" + assert parsed_query["client_id"][0] == CLIENT_ID + assert parsed_query["redirect_uri"][0] == REDIRECT + assert parsed_query["state"][0] == state + assert parsed_query["code_challenge"][0] + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + response = { + "refresh_token": "test_refresh_token", + "access_token": "test_access_token", + "type": "Bearer", + "expires_in": 60, + } + + aioclient_mock.clear_requests() + aioclient_mock.post( + TOKEN_URL, + json=response, + ) + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == UNIQUE_ID + assert result["data"]["auth_implementation"] == "teslemetry" + assert result["data"]["token"]["refresh_token"] == response["refresh_token"] + assert result["data"]["token"]["access_token"] == response["access_token"] + assert result["data"]["token"]["type"] == response["type"] + assert result["data"]["token"]["expires_in"] == response["expires_in"] + assert "expires_at" in result["result"].data["token"] -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (InvalidToken, {CONF_ACCESS_TOKEN: "invalid_access_token"}), - (SubscriptionRequired, {"base": "subscription_required"}), - (ClientConnectionError, {"base": "cannot_connect"}), - (TeslaFleetError, {"base": "unknown"}), - ], -) -async def test_form_errors( +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth( hass: HomeAssistant, - side_effect: TeslaFleetError, - error: dict[str, str], - mock_metadata: AsyncMock, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test errors are handled.""" - - result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_metadata.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == error - - # Complete the flow - mock_metadata.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - CONFIG, - ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - - -async def test_reauth(hass: HomeAssistant, mock_metadata: AsyncMock) -> None: """Test reauth flow.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, data=BAD_CONFIG, minor_version=2, unique_id="abc-123" - ) - mock_entry.add_to_hass(hass) - - result1 = await mock_entry.start_reauth_flow(hass) - - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "reauth_confirm" - assert not result1["errors"] - - with patch( - "homeassistant.components.teslemetry.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - CONFIG, - ) - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_metadata.mock_calls) == 1 - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert mock_entry.data == CONFIG - - -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (InvalidToken, {CONF_ACCESS_TOKEN: "invalid_access_token"}), - (SubscriptionRequired, {"base": "subscription_required"}), - (ClientConnectionError, {"base": "cannot_connect"}), - (TeslaFleetError, {"base": "unknown"}), - ], -) -async def test_reauth_errors( - hass: HomeAssistant, - mock_metadata: AsyncMock, - side_effect: TeslaFleetError, - error: dict[str, str], -) -> None: - """Test reauth flows that fail.""" - - # Start the reauth - mock_entry = MockConfigEntry( - domain=DOMAIN, data=BAD_CONFIG, minor_version=2, unique_id="abc-123" - ) - mock_entry.add_to_hass(hass) + mock_entry = await setup_platform(hass, []) result = await mock_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" - mock_metadata.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - BAD_CONFIG, + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + # Progress from reauth_confirm to external OAuth step + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, ) - await hass.async_block_till_done() + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == error - - # Complete the flow - mock_metadata.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - CONFIG, + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "test_refresh_token", + "access_token": "test_access_token", + "type": "Bearer", + "expires_in": 60, + }, ) - assert "errors" not in result3 - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" - assert mock_entry.data == CONFIG + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" -async def test_unique_id_abort( +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test duplicate unique ID in config.""" - - result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG - ) - assert result1["type"] is FlowResultType.CREATE_ENTRY - - # Setup a duplicate - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG - ) - assert result2["type"] is FlowResultType.ABORT - - -async def test_migrate_from_1_1(hass: HomeAssistant, mock_metadata: AsyncMock) -> None: - """Test config migration.""" - - mock_entry = MockConfigEntry( + """Test Tesla Fleet reauthentication with different account.""" + # Create an entry with a different unique_id to test account mismatch + old_entry = MockConfigEntry( domain=DOMAIN, - version=1, - minor_version=1, - unique_id=None, - data=CONFIG, + version=2, + unique_id="baduid", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "old_access_token", + "refresh_token": "old_refresh_token", + "expires_at": int(time.time()) + 3600, + }, + }, + ) + old_entry.add_to_hass(hass) + + # Setup the integration properly to import client credentials + with patch( + "homeassistant.components.teslemetry.async_setup_entry", return_value=True + ): + await hass.config_entries.async_setup(old_entry.entry_id) + await hass.async_block_till_done() + + result = await old_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "test_access_token", + "type": "Bearer", + "expires_in": 60, + }, ) - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.teslemetry.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - entry = hass.config_entries.async_get_entry(mock_entry.entry_id) - assert entry.version == 1 - assert entry.minor_version == 2 - assert entry.unique_id == METADATA["uid"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" -async def test_migrate_error_from_1_1( - hass: HomeAssistant, mock_metadata: AsyncMock +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_unique_id_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test config migration handles errors.""" + """Test duplicate unique ID aborts flow.""" + # Create existing entry + await setup_platform(hass, []) - mock_metadata.side_effect = TeslaFleetError - - mock_entry = MockConfigEntry( - domain=DOMAIN, - version=1, - minor_version=1, - unique_id=None, - data=CONFIG, + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) - entry = hass.config_entries.async_get_entry(mock_entry.entry_id) - assert entry.state is ConfigEntryState.MIGRATION_ERROR + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + # Complete OAuth - should abort due to duplicate unique_id + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + "exception", + [ + InvalidToken, + SubscriptionRequired, + ClientConnectionError, + TeslaFleetError("API error"), + ], +) +async def test_oauth_error_handling( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + exception: Exception, +) -> None: + """Test OAuth flow with various API errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "test_refresh_token", + "access_token": "test_access_token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "tesla_fleet_api.teslemetry.Teslemetry.metadata", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth_error" async def test_migrate_error_from_future( @@ -237,10 +311,10 @@ async def test_migrate_error_from_future( mock_entry = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, minor_version=1, unique_id="abc-123", - data=CONFIG, + data=CONFIG_V1, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 00e8d54c9fe..9f0415459fd 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,7 +1,9 @@ """Test the Teslemetry init.""" -from unittest.mock import AsyncMock, patch +import time +from unittest.mock import AsyncMock, MagicMock, patch +from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -11,11 +13,12 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) -from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + CONF_ACCESS_TOKEN, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -26,7 +29,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import PRODUCTS_MODERN, VEHICLE_DATA_ALT +from .const import CONFIG_V1, PRODUCTS_MODERN, UNIQUE_ID, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), @@ -127,9 +132,11 @@ async def test_vehicle_stream( mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") + assert state is not None assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") + assert state is not None assert state.state == STATE_UNAVAILABLE mock_add_listener.send( @@ -143,9 +150,11 @@ async def test_vehicle_stream( await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") + assert state is not None assert state.state == STATE_ON state = hass.states.get("binary_sensor.test_user_present") + assert state is not None assert state.state == STATE_ON mock_add_listener.send( @@ -158,6 +167,7 @@ async def test_vehicle_stream( await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") + assert state is not None assert state.state == STATE_OFF @@ -279,3 +289,144 @@ async def test_device_retention_during_reload( # Since the products data didn't change, we should have the same devices assert post_count == pre_count assert post_identifiers == original_identifiers + + +async def test_migrate_from_version_1_success(hass: HomeAssistant) -> None: + """Test successful config migration from version 1.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=UNIQUE_ID, + data=CONFIG_V1, + ) + + # Mock the migrate token endpoint response + with patch( + "homeassistant.components.teslemetry.Teslemetry.migrate_to_oauth", + new_callable=AsyncMock, + ) as mock_migrate: + mock_migrate.return_value = { + "token": { + "access_token": "migrated_token", + "token_type": "Bearer", + "refresh_token": "migrated_refresh_token", + "expires_in": 3600, + "expires_at": time.time() + 3600, + } + } + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + mock_migrate.assert_called_once_with( + CLIENT_ID, CONFIG_V1[CONF_ACCESS_TOKEN], hass.config.location_name + ) + + assert mock_entry is not None + assert mock_entry.version == 2 + # Verify data was converted to OAuth format + assert "token" in mock_entry.data + assert mock_entry.data["token"]["access_token"] == "migrated_token" + assert mock_entry.data["token"]["refresh_token"] == "migrated_refresh_token" + # Verify auth_implementation was added for OAuth2 flow compatibility + assert mock_entry.data["auth_implementation"] == DOMAIN + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_migrate_from_version_1_token_endpoint_error(hass: HomeAssistant) -> None: + """Test config migration handles token endpoint errors.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=UNIQUE_ID, + data=CONFIG_V1, + ) + + # Mock the migrate token endpoint to raise an HTTP error + with patch( + "homeassistant.components.teslemetry.Teslemetry.migrate_to_oauth", + new_callable=AsyncMock, + ) as mock_migrate: + mock_migrate.side_effect = ClientResponseError( + request_info=MagicMock(), history=(), status=400 + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + mock_migrate.assert_called_once_with( + CLIENT_ID, CONFIG_V1[CONF_ACCESS_TOKEN], hass.config.location_name + ) + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry is not None + assert entry.state is ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 # Version should remain unchanged on migration failure + + +async def test_migrate_version_2_no_migration_needed(hass: HomeAssistant) -> None: + """Test that version 2 entries don't need migration.""" + oauth_config = { + "auth_implementation": DOMAIN, + "token": { + "access_token": "existing_oauth_token", + "token_type": "Bearer", + "refresh_token": "existing_refresh_token", + "expires_in": 3600, + "expires_at": 1234567890, + }, + } + + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=2, # Already current version + unique_id=UNIQUE_ID, + data=oauth_config, + ) + + # Should not call the migrate endpoint since already version 2 + with patch( + "homeassistant.components.teslemetry.Teslemetry.migrate_to_oauth", + new_callable=AsyncMock, + ) as mock_migrate: + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Migration should not be called + mock_migrate.assert_not_called() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry is not None + assert entry.version == 2 + # Verify data was not modified + assert entry.data == oauth_config + assert entry.state is ConfigEntryState.LOADED + + +async def test_migrate_from_future_version_fails(hass: HomeAssistant) -> None: + """Test migration fails for future versions.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=3, # Future version + unique_id=UNIQUE_ID, + data={ + "token": { + "access_token": "future_token", + "token_type": "Bearer", + "refresh_token": "future_refresh_token", + "expires_in": 3600, + } + }, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry is not None + assert entry.state is ConfigEntryState.MIGRATION_ERROR + assert entry.version == 3 # Version should remain unchanged