diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index bc7fee46f60..ddd736b47c0 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -9,7 +9,7 @@ from bosch_alarm_mode2 import Panel from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN @@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) - await panel.connect() except (PermissionError, ValueError) as err: await panel.disconnect() - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: await panel.disconnect() - raise ConfigEntryNotReady("Connection failed") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err entry.runtime_data = panel diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index e48f2a11944..4b1e3e511fc 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import ssl from typing import Any @@ -163,3 +164,55 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + 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(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 467760fb863..75c331ede40 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index f4846021b55..3123c1697f3 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -22,6 +22,18 @@ "installer_code": "The installer code from your panel", "user_code": "The user code from your panel" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } } }, "error": { @@ -30,7 +42,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "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%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." } } } diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 066b3008821..4a1c9dad3ea 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -210,3 +212,74 @@ async def test_entry_already_configured_serial( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py index 0497a91eadf..13e938bd711 100644 --- a/tests/components/bosch_alarm/test_init.py +++ b/tests/components/bosch_alarm/test_init.py @@ -20,12 +20,26 @@ def disable_platform_only(): @pytest.mark.parametrize("model", ["solution_3000"]) -@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()]) +@pytest.mark.parametrize("exception", [PermissionError()]) async def test_incorrect_auth( hass: HomeAssistant, mock_panel: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, ) -> None: """Test errors with incorrect auth.""" mock_panel.connect.side_effect = exception