diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index b6474936dfe..87acc7a84ed 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -24,6 +24,29 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input and return errors dict.""" + errors: dict[str, str] = {} + session = async_get_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = UnifiAccessApiClient( + host=user_input[CONF_HOST], + api_token=user_input[CONF_API_TOKEN], + session=session, + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + try: + await client.authenticate() + except ApiAuthError: + errors["base"] = "invalid_auth" + except ApiConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -31,26 +54,9 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] - ) - client = UnifiAccessApiClient( - host=user_input[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=user_input[CONF_VERIFY_SSL], - ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + errors = await self._validate_input(user_input) + if not errors: return self.async_create_entry( title="UniFi Access", data=user_input, @@ -68,6 +74,40 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST]}, + ) + errors = await self._validate_input(user_input) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + suggested_values = user_input or dict(reconfigure_entry.data) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL): bool, + } + ), + suggested_values, + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -82,25 +122,13 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=reauth_entry.data[CONF_VERIFY_SSL] + errors = await self._validate_input( + { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } ) - client = UnifiAccessApiClient( - host=reauth_entry.data[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=reauth_entry.data[CONF_VERIFY_SSL], - ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not errors: return self.async_update_reload_and_abort( reauth_entry, data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index a593584d940..42a8ac4cfca 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -58,7 +58,7 @@ rules: entity-translations: todo exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index cd6e72bc9e5..44cf6dd921b 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,19 @@ }, "description": "The API token for UniFi Access at {host} is invalid. Please provide a new token." }, + "reconfigure": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]", + "host": "[%key:component::unifi_access::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]" + }, + "description": "Update the connection settings of this UniFi Access controller." + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", diff --git a/tests/components/unifi_access/test_config_flow.py b/tests/components/unifi_access/test_config_flow.py index 5a25620ecb0..4c6c77b5f0b 100644 --- a/tests/components/unifi_access/test_config_flow.py +++ b/tests/components/unifi_access/test_config_flow.py @@ -216,3 +216,148 @@ async def test_reauth_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "10.0.0.1" + assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token" + assert mock_config_entry.data[CONF_VERIFY_SSL] is True + + +async def test_reconfigure_flow_same_host_new_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow with same host and new API token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == MOCK_HOST + assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token" + + +async def test_reconfigure_flow_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow aborts when host already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "other-token", + CONF_VERIFY_SSL: False, + }, + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ApiConnectionError("Connection failed"), "cannot_connect"), + (ApiAuthError(), "invalid_auth"), + (RuntimeError("boom"), "unknown"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfiguration flow errors and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful"