From 995f84c004186094bc8c7888951c24cf0b403a35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:08:29 +0200 Subject: [PATCH] Add re-auth and re-configure flow to libreNMS (#180301) --- .../components/librenms/config_flow.py | 119 ++++++++++ .../components/librenms/quality_scale.yaml | 4 +- .../components/librenms/strings.json | 23 +- tests/components/librenms/test_config_flow.py | 219 +++++++++++++++++- 4 files changed, 359 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/librenms/config_flow.py b/homeassistant/components/librenms/config_flow.py index 777d31837866..2cc54a32bfe2 100644 --- a/homeassistant/components/librenms/config_flow.py +++ b/homeassistant/components/librenms/config_flow.py @@ -57,6 +57,7 @@ def _parse_url(url: str) -> tuple[str, int, bool]: (host := parsed_url.host) is None or (port := parsed_url.port) is None or (scheme := parsed_url.scheme) is None + or scheme not in ["http", "https"] ): raise InvalidUrl return host, port, scheme == "https" @@ -123,3 +124,121 @@ class LibrenmsConfigFlow(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: + """Trigger a reauthentication flow.""" + self._current_data = entry_data + self._name = entry_data[CONF_HOST] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + try: + await check_connection( + self.hass, + self._current_data[CONF_HOST], + self._current_data[CONF_PORT], + self._current_data[CONF_SSL], + self._current_data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except LibrenmsUnauthenticatedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } + ), + description_placeholders={"name": self._name}, + errors=errors, + ) + + async def async_step_reconfigure( + self, + user_input: Mapping[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of LibreNMS.""" + entry = self._get_reconfigure_entry() + current_data = entry.data + + url = str( + URL.build( + scheme="https" if current_data[CONF_SSL] else "http", + host=current_data[CONF_HOST], + port=current_data[CONF_PORT], + ) + ) + verify_ssl = current_data[CONF_VERIFY_SSL] + + errors: dict[str, str] = {} + if user_input is not None: + url = user_input[CONF_URL] + verify_ssl = user_input[CONF_VERIFY_SSL] + try: + (host, port, ssl) = _parse_url(user_input[CONF_URL]) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + else: + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + await check_connection( + self.hass, + host, + port, + ssl, + user_input[CONF_VERIFY_SSL], + current_data[CONF_API_KEY], + ) + except LibrenmsUnauthenticatedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates={ + **current_data, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_URL, default=url): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_VERIFY_SSL, default=verify_ssl): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/librenms/quality_scale.yaml b/homeassistant/components/librenms/quality_scale.yaml index 17a61715387e..5fd10affe884 100644 --- a/homeassistant/components/librenms/quality_scale.yaml +++ b/homeassistant/components/librenms/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair issues needed diff --git a/homeassistant/components/librenms/strings.json b/homeassistant/components/librenms/strings.json index 9ed385604610..66f688606039 100644 --- a/homeassistant/components/librenms/strings.json +++ b/homeassistant/components/librenms/strings.json @@ -6,7 +6,9 @@ }, "config": { "abort": { - "already_configured": "This LibreNMS instance is already configured." + "already_configured": "This LibreNMS instance is already configured.", + "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%]", @@ -15,6 +17,25 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::librenms::common::data_desc_api_key%]" + }, + "description": "Update the API key for {name}." + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:component::librenms::common::data_desc_url%]", + "verify_ssl": "[%key:component::librenms::common::data_desc_ssl_verify%]" + } + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/tests/components/librenms/test_config_flow.py b/tests/components/librenms/test_config_flow.py index de9e4d3586c5..f05e92f4bd9f 100644 --- a/tests/components/librenms/test_config_flow.py +++ b/tests/components/librenms/test_config_flow.py @@ -8,7 +8,14 @@ import pytest from homeassistant.components.librenms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_URL +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -80,8 +87,14 @@ async def test_step_user_error_handling( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.parametrize( + ("invalid_url"), + ["hts://invalid", "hts://invalid:123"], +) @pytest.mark.usefixtures("mock_setup_entry") -async def test_step_user_invalid_url(hass: HomeAssistant, mock_librenms: Mock) -> None: +async def test_step_user_invalid_url( + hass: HomeAssistant, mock_librenms: Mock, invalid_url: str +) -> None: """Test a user initiated config flow with errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -91,7 +104,7 @@ async def test_step_user_invalid_url(hass: HomeAssistant, mock_librenms: Mock) - result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**MOCK_USER_DATA, CONF_URL: "hts://invalid"}, + {**MOCK_USER_DATA, CONF_URL: invalid_url}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -123,3 +136,203 @@ async def test_user_already_configured( ) 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_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test reauthentication flow.""" + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + LibrenmsUnauthenticatedError({"message": "Unauthenticated."}), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow with errors.""" + 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_librenms.system.async_get_system_info.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_librenms.system.async_get_system_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure 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_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "librenms" + assert mock_config_entry.data[CONF_PORT] == 8443 + assert mock_config_entry.data[CONF_SSL] is True + assert mock_config_entry.data[CONF_VERIFY_SSL] is True + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + LibrenmsUnauthenticatedError({"message": "Unauthenticated."}), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reconfigure_error_handling( + hass: HomeAssistant, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test a user initiated config flow with errors.""" + 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_librenms.system.async_get_system_info.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": error} + + mock_librenms.system.async_get_system_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reconfigure_invalid_url( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test a user initiated config flow with errors.""" + 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_URL: "hts://invalid"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {CONF_URL: "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_already_configured( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test duplicate-reconfiguration guard.""" + mock_config_entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "librenms2", + CONF_API_KEY: "abcdef0123456789", + CONF_PORT: 8443, + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + title="librenms2", + ) + + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + + result = await mock_config_entry2.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_URL: "https://librenms:443", CONF_VERIFY_SSL: True}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"