diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 2b4ab0f8c1b..8c7bca7a913 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -4,10 +4,12 @@ from __future__ import annotations import asyncio from sfrbox_api.bridge import SFRBox +from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client @@ -19,6 +21,16 @@ from .models import DomainData async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SFR box as config entry.""" box = SFRBox(ip=entry.data[CONF_HOST], client=get_async_client(hass)) + if (username := entry.data.get(CONF_USERNAME)) and ( + password := entry.data.get(CONF_PASSWORD) + ): + try: + await box.authenticate(username=username, password=password) + except SFRBoxAuthenticationError as err: + raise ConfigEntryAuthFailed() from err + except SFRBoxError as err: + raise ConfigEntryNotReady() from err + data = DomainData( dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), system=SFRDataUpdateCoordinator( diff --git a/homeassistant/components/sfr_box/config_flow.py b/homeassistant/components/sfr_box/config_flow.py index e4fe71db9c6..2575aa6d467 100644 --- a/homeassistant/components/sfr_box/config_flow.py +++ b/homeassistant/components/sfr_box/config_flow.py @@ -1,13 +1,14 @@ """SFR Box config flow.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -34,8 +35,9 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN): """SFR Box config flow.""" VERSION = 1 - _config: dict[str, Any] = {} _box: SFRBox + _config: dict[str, Any] = {} + _reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, str] | None = None @@ -84,10 +86,21 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN): except SFRBoxAuthenticationError: errors["base"] = "invalid_auth" else: + if reauth_entry := self._reauth_entry: + data = {**reauth_entry.data, **user_input} + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") self._config.update(user_input) return self.async_create_entry(title="SFR Box", data=self._config) - data_schema = self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input) + suggested_values: Mapping[str, Any] | None = user_input + if self._reauth_entry and not suggested_values: + suggested_values = self._reauth_entry.data + + data_schema = self.add_suggested_values_to_schema(AUTH_SCHEMA, suggested_values) return self.async_show_form( step_id="auth", data_schema=data_schema, errors=errors ) @@ -97,3 +110,11 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Skip authentication.""" return self.async_create_entry(title="SFR Box", data=self._config) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle failed credentials.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._box = SFRBox(ip=entry_data[CONF_HOST], client=get_async_client(self.hass)) + return await self.async_step_auth() diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 094d3ccfda1..ddff342a10d 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/sfr_box/translations/en.json b/homeassistant/components/sfr_box/translations/en.json index 82a2f9b6868..59675fa7844 100644 --- a/homeassistant/components/sfr_box/translations/en.json +++ b/homeassistant/components/sfr_box/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index f55945c594c..922becf9bde 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -8,7 +8,7 @@ from sfrbox_api.models import DslInfo, SystemInfo from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -29,6 +29,25 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: return config_entry +@pytest.fixture(name="config_entry_with_auth") +def get_config_entry_with_auth(hass: HomeAssistant) -> ConfigEntry: + """Create and register mock config entry.""" + config_entry_with_auth = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOST: "192.168.0.1", + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + unique_id="e4:5d:51:00:11:23", + options={}, + entry_id="1234567", + ) + config_entry_with_auth.add_to_hass(hass) + return config_entry_with_auth + + @pytest.fixture() def system_get_info() -> Generator[SystemInfo, None, None]: """Fixture for SFRBox.system_get_info.""" diff --git a/tests/components/sfr_box/test_config_flow.py b/tests/components/sfr_box/test_config_flow.py index bca302f04af..3b15907e36b 100644 --- a/tests/components/sfr_box/test_config_flow.py +++ b/tests/components/sfr_box/test_config_flow.py @@ -1,4 +1,5 @@ """Test the SFR Box config flow.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, patch @@ -8,6 +9,7 @@ from sfrbox_api.models import SystemInfo from homeassistant import config_entries, data_entry_flow from homeassistant.components.sfr_box.const import DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -15,7 +17,7 @@ from tests.common import load_fixture @pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> AsyncMock: +def override_async_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" with patch( "homeassistant.components.sfr_box.async_setup_entry", return_value=True @@ -201,3 +203,50 @@ async def test_config_flow_duplicate_mac( await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth(hass: HomeAssistant, config_entry_with_auth: ConfigEntry) -> None: + """Test the start of the config flow.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry_with_auth.entry_id, + "unique_id": config_entry_with_auth.unique_id, + }, + data=config_entry_with_auth.data, + ) + + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {} + + # Failed credentials + with patch( + "homeassistant.components.sfr_box.config_flow.SFRBox.authenticate", + side_effect=SFRBoxAuthenticationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "admin", + CONF_PASSWORD: "invalid", + }, + ) + + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_auth"} + + # Valid credentials + with patch("homeassistant.components.sfr_box.config_flow.SFRBox.authenticate"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "admin", + CONF_PASSWORD: "new_password", + }, + ) + + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/sfr_box/test_init.py b/tests/components/sfr_box/test_init.py index 48bf07fc5e0..3a740753b21 100644 --- a/tests/components/sfr_box/test_init.py +++ b/tests/components/sfr_box/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest -from sfrbox_api.exceptions import SFRBoxError +from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -48,3 +48,35 @@ async def test_setup_entry_exception( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_auth_exception( + hass: HomeAssistant, config_entry_with_auth: ConfigEntry +) -> None: + """Test ConfigEntryNotReady when API raises an exception during authentication.""" + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.authenticate", + side_effect=SFRBoxError, + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_invalid_auth( + hass: HomeAssistant, config_entry_with_auth: ConfigEntry +) -> None: + """Test ConfigEntryAuthFailed when API raises an exception during authentication.""" + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.authenticate", + side_effect=SFRBoxAuthenticationError, + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN)