diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e792780e873..ac77c3cc09e 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,4 +1,5 @@ """Tessie integration.""" +from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError @@ -7,7 +8,7 @@ from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -28,9 +29,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key=api_key, only_active=True, ) - except ClientResponseError as ex: - # Reauth will go here - _LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) return False except ClientError as e: raise ConfigEntryNotReady from e diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index c286f43c8b3..4379a810309 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -10,6 +10,7 @@ from tessie_api import get_state_of_all_vehicles import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -24,12 +25,16 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: """Get configuration from the user.""" errors: dict[str, str] = {} - if user_input and CONF_ACCESS_TOKEN in user_input: + if user_input: try: await get_state_of_all_vehicles( session=async_get_clientsession(self.hass), @@ -54,3 +59,44 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=TESSIE_SCHEMA, errors=errors, ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 7a1efb985ee..7a2a8c71c56 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,6 +8,7 @@ from aiohttp import ClientResponseError from tessie_api import get_state from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -57,7 +58,9 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): # Vehicle is offline, only update state and dont throw error self.data["state"] = TessieStatus.OFFLINE return self.data - # Reauth will go here + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e raise e self.did_first_update = True diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 2069e46cecc..5d57075241c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -11,6 +11,13 @@ "access_token": "[%key:common::config_flow::data::access_token%]" }, "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index edf2f8914ae..d1977a13193 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -14,8 +16,21 @@ from .common import ( ERROR_UNKNOWN, TEST_CONFIG, TEST_STATE_OF_ALL_VEHICLES, + setup_platform, ) +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -137,3 +152,89 @@ async def test_form_network_issue(hass: HomeAssistant) -> None: TEST_CONFIG, ) assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {"base": "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 43b6489c39d..8fe92454c36 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .common import ( + ERROR_AUTH, ERROR_CONNECTION, ERROR_TIMEOUT, ERROR_UNKNOWN, @@ -81,6 +82,17 @@ async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: """Tests that the coordinator handles connection errors.""" diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 409ece97a24..8c12979b9d5 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform async def test_load_unload(hass: HomeAssistant) -> None: @@ -16,6 +16,13 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_unknown_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure."""