diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py index a5e0c50505d00..757e36cbe3f92 100644 --- a/homeassistant/components/data_grand_lyon/config_flow.py +++ b/homeassistant/components/data_grand_lyon/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Data Grand Lyon integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -72,6 +73,36 @@ async def async_step_user( errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with new credentials.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + if error := await self._test_connection(user_input): + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + ), + errors=errors, + ) + async def _test_connection(self, user_input: dict[str, Any]) -> str | None: """Test connectivity by making a dummy API call. diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py index 78e27258e33b9..e93b3f16f0f23 100644 --- a/homeassistant/components/data_grand_lyon/coordinator.py +++ b/homeassistant/components/data_grand_lyon/coordinator.py @@ -3,10 +3,12 @@ import asyncio from datetime import timedelta +from aiohttp import ClientResponseError from data_grand_lyon_ha import DataGrandLyonClient, TclPassage from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_STOP @@ -57,6 +59,13 @@ async def _async_update_data(self) -> dict[str, list[TclPassage]]: for i, subentry in enumerate(stop_subentries): result = stop_results[i] if isinstance(result, BaseException): + if isinstance(result, ClientResponseError) and result.status in ( + 401, + 403, + ): + raise ConfigEntryAuthFailed( + "Authentication failed for Data Grand Lyon" + ) from result LOGGER.warning( "Error fetching departures for stop %s: %s", subentry.subentry_id, diff --git a/homeassistant/components/data_grand_lyon/quality_scale.yaml b/homeassistant/components/data_grand_lyon/quality_scale.yaml index 03e114feef493..6c739b099fb39 100644 --- a/homeassistant/components/data_grand_lyon/quality_scale.yaml +++ b/homeassistant/components/data_grand_lyon/quality_scale.yaml @@ -36,7 +36,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json index 407108bf36caf..5b3a6b10f5c0b 100644 --- a/homeassistant/components/data_grand_lyon/strings.json +++ b/homeassistant/components/data_grand_lyon/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]", + "username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/data_grand_lyon/test_config_flow.py b/tests/components/data_grand_lyon/test_config_flow.py index 784bde4e48d29..6c508f5fcfcb0 100644 --- a/tests/components/data_grand_lyon/test_config_flow.py +++ b/tests/components/data_grand_lyon/test_config_flow.py @@ -92,6 +92,85 @@ async def test_form_error_recovers( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth flow updates credentials on success.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.data_grand_lyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-user", CONF_PASSWORD: "new-pass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-pass", + } + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ClientConnectionError(), "cannot_connect"), + (ClientResponseError(None, None, status=401), "invalid_auth"), + (ClientResponseError(None, None, status=500), "cannot_connect"), + (RuntimeError("unexpected"), "unknown"), + ], + ids=["connection-error", "auth-401", "http-500", "unknown"], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test the reauth flow shows errors and recovers.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + with patch( + "homeassistant.components.data_grand_lyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-user", CONF_PASSWORD: "new-pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + with patch( + "homeassistant.components.data_grand_lyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-user", CONF_PASSWORD: "new-pass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-pass", + } + + async def test_form_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/data_grand_lyon/test_init.py b/tests/components/data_grand_lyon/test_init.py index 51138121de685..a73b4f2ccde9f 100644 --- a/tests/components/data_grand_lyon/test_init.py +++ b/tests/components/data_grand_lyon/test_init.py @@ -3,12 +3,15 @@ from types import MappingProxyType from unittest.mock import AsyncMock +from aiohttp import ClientResponseError + from homeassistant.components.data_grand_lyon.const import ( CONF_LINE, CONF_STOP_ID, + DOMAIN, SUBENTRY_TYPE_STOP, ) -from homeassistant.config_entries import ConfigSubentry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState, ConfigSubentry from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -42,3 +45,23 @@ async def test_subentry_added_reloads( assert mock_tcl_client.get_tcl_passages.call_count > initial_call_count assert hass.states.get("sensor.t1_stop_200_next_departure_1") is not None + + +async def test_setup_triggers_reauth_on_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that an auth failure during setup triggers a reauth flow.""" + mock_tcl_client.get_tcl_passages.side_effect = ClientResponseError( + None, None, status=401 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert any(flow["context"].get("source") == SOURCE_REAUTH for flow in flows)