From 855962dcd0fee761857dbfb2fdfa9ceb3bc6ca59 Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:01:20 +0200 Subject: [PATCH] Add reauthentication flow to Fluss+ (#173341) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/fluss/config_flow.py | 56 ++++++++--- homeassistant/components/fluss/coordinator.py | 4 +- .../components/fluss/quality_scale.yaml | 2 +- homeassistant/components/fluss/strings.json | 12 ++- tests/components/fluss/test_config_flow.py | 97 +++++++++++++++++++ 5 files changed, 155 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fluss/config_flow.py b/homeassistant/components/fluss/config_flow.py index 202cb91bde27..6c7b184f36bf 100644 --- a/homeassistant/components/fluss/config_flow.py +++ b/homeassistant/components/fluss/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Fluss+ integration.""" +from collections.abc import Mapping from typing import Any from fluss_api import ( @@ -22,6 +23,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string}) class FlussConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fluss+.""" + async def _validate_api_key(self, api_key: str) -> dict[str, str]: + """Validate the API key and return any errors.""" + errors: dict[str, str] = {} + client = FlussApiClient(api_key, session=async_get_clientsession(self.hass)) + try: + await client.async_get_devices() + except FlussApiClientCommunicationError: + errors["base"] = "cannot_connect" + except FlussApiClientAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception occurred") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -31,18 +47,7 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: api_key = user_input[CONF_API_KEY] self._async_abort_entries_match({CONF_API_KEY: api_key}) - client = FlussApiClient( - user_input[CONF_API_KEY], session=async_get_clientsession(self.hass) - ) - try: - await client.async_get_devices() - except FlussApiClientCommunicationError: - errors["base"] = "cannot_connect" - except FlussApiClientAuthenticationError: - errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception occurred") - errors["base"] = "unknown" + errors = await self._validate_api_key(api_key) if not errors: return self.async_create_entry( title="My Fluss+ Devices", data=user_input @@ -51,3 +56,30 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication when the API key is no longer valid.""" + 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 a new API key.""" + errors: dict[str, str] = {} + if user_input is not None: + api_key = user_input[CONF_API_KEY] + self._async_abort_entries_match({CONF_API_KEY: api_key}) + errors = await self._validate_api_key(api_key) + if not errors: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_API_KEY: api_key}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index 36df9298eb7f..348968c166bf 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -12,7 +12,7 @@ from fluss_api import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -60,7 +60,7 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]] try: devices = await self.api.async_get_devices() except FlussApiClientAuthenticationError as err: - raise ConfigEntryError(f"Authentication failed: {err}") from err + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err except FlussApiClientError as err: raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml index 0009c1a7a958..7d4cdfac5bc8 100644 --- a/homeassistant/components/fluss/quality_scale.yaml +++ b/homeassistant/components/fluss/quality_scale.yaml @@ -29,7 +29,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold entity-translations: done diff --git a/homeassistant/components/fluss/strings.json b/homeassistant/components/fluss/strings.json index adc19193b68b..219a27daa5d6 100644 --- a/homeassistant/components/fluss/strings.json +++ b/homeassistant/components/fluss/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key found in the profile page of the Fluss+ app." + }, + "description": "The Fluss+ API key is no longer valid. Get your API key from the profile page of the Fluss+ app, or generate a new one, and enter it below." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/tests/components/fluss/test_config_flow.py b/tests/components/fluss/test_config_flow.py index 4044634a29fa..9626b6e5c3e7 100644 --- a/tests/components/fluss/test_config_flow.py +++ b/tests/components/fluss/test_config_flow.py @@ -16,6 +16,8 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +NEW_API_KEY = "new_api_key" + @pytest.mark.usefixtures("mock_setup_entry") async def test_full_flow(hass: HomeAssistant, mock_api_client: AsyncMock) -> None: @@ -103,3 +105,98 @@ async def test_duplicate_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication updates the API key on the existing entry.""" + 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" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: NEW_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == NEW_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_duplicate( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth aborts when the new key is already used by another entry.""" + mock_config_entry.add_to_hass(hass) + other_entry = MockConfigEntry( + domain=DOMAIN, + title="My Fluss+ Devices", + data={CONF_API_KEY: NEW_API_KEY}, + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: NEW_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_API_KEY] == "test_api_key" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (FlussApiClientAuthenticationError, "invalid_auth"), + (FlussApiClientCommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reauthentication error cases with recovery.""" + 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" + + mock_api_client.async_get_devices.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: NEW_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": expected_error} + + mock_api_client.async_get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: NEW_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == NEW_API_KEY