From 7bceaf74bedf228ec50900fdba09debd1beb4b6d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 25 Oct 2025 19:13:58 +0200 Subject: [PATCH] Support reconfigure flow in NextDNS integration (#154936) --- .../components/nextdns/config_flow.py | 87 ++++++++++-- .../components/nextdns/manifest.json | 2 +- .../components/nextdns/quality_scale.yaml | 4 +- homeassistant/components/nextdns/strings.json | 12 +- tests/components/nextdns/test_config_flow.py | 127 +++++++++++++++++- 5 files changed, 212 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index d36064d8fb0..637e698f112 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_PROFILE_ID, DOMAIN @@ -23,11 +24,40 @@ AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) _LOGGER = logging.getLogger(__name__) -async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: - """Check if credentials are valid.""" +async def async_init_nextdns( + hass: HomeAssistant, api_key: str, profile_id: str | None = None +) -> NextDns: + """Check if credentials and profile_id are valid.""" websession = async_get_clientsession(hass) - return await NextDns.create(websession, api_key) + nextdns = await NextDns.create(websession, api_key) + + if profile_id: + if not any(profile.id == profile_id for profile in nextdns.profiles): + raise ProfileNotAvailable + + return nextdns + + +async def async_validate_new_api_key( + hass: HomeAssistant, user_input: dict[str, Any], profile_id: str +) -> dict[str, str]: + """Validate the new API key during reconfiguration or reauth.""" + errors: dict[str, str] = {} + + try: + await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id) + except InvalidApiKeyError: + errors["base"] = "invalid_api_key" + except (ApiError, ClientConnectorError, RetryError, TimeoutError): + errors["base"] = "cannot_connect" + except ProfileNotAvailable: + errors["base"] = "profile_not_available" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): @@ -107,20 +137,19 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} + entry = self._get_reauth_entry() if user_input is not None: - try: - await async_init_nextdns(self.hass, user_input[CONF_API_KEY]) - except InvalidApiKeyError: - errors["base"] = "invalid_api_key" - except (ApiError, ClientConnectorError, RetryError, TimeoutError): - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors = await async_validate_new_api_key( + self.hass, user_input, entry.data[CONF_PROFILE_ID] + ) + if errors.get("base") == "profile_not_available": + return self.async_abort(reason="profile_not_available") + + if not errors: return self.async_update_reload_and_abort( - self._get_reauth_entry(), data_updates=user_input + entry, + data_updates=user_input, ) return self.async_show_form( @@ -128,3 +157,33 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=AUTH_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + errors = await async_validate_new_api_key( + self.hass, user_input, entry.data[CONF_PROFILE_ID] + ) + if errors.get("base") == "profile_not_available": + return self.async_abort(reason="profile_not_available") + + if not errors: + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=AUTH_SCHEMA, + errors=errors, + ) + + +class ProfileNotAvailable(HomeAssistantError): + """Error to indicate that the profile is not available after reconfig/reauth.""" diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index e2cbb861273..8b4acff9073 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["nextdns==4.1.0"] } diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml index 24b5b43d6d1..c8243232b0a 100644 --- a/homeassistant/components/nextdns/quality_scale.yaml +++ b/homeassistant/components/nextdns/quality_scale.yaml @@ -68,9 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: Allow API key to be changed in the re-configure flow. + reconfiguration-flow: done repair-issues: status: exempt comment: This integration doesn't have any cases where raising an issue is needed. diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 2d12fb066d9..700e7a889f4 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -24,6 +24,14 @@ "data_description": { "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" } + }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -33,7 +41,9 @@ }, "abort": { "already_configured": "This NextDNS profile is already configured.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "system_health": { diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 1ed5a59a5bf..280dcc8336b 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from nextdns import ApiError, InvalidApiKeyError +from nextdns import ApiError, InvalidApiKeyError, ProfileInfo import pytest from tenacity import RetryError @@ -154,6 +154,32 @@ async def test_reauth_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reauth_no_profile( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test reauthentication flow when the profile is no longer available.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_nextdns_client.profiles = [ + ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile") + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "profile_not_available" @pytest.mark.parametrize( @@ -196,6 +222,105 @@ async def test_reauth_errors( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test starting a reconfigure flow.""" + await init_integration(hass, mock_config_entry) + + 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_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ApiError("API Error"), "cannot_connect"), + (InvalidApiKeyError, "invalid_api_key"), + (RetryError("Retry Error"), "cannot_connect"), + (TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_reconfiguration_errors( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, +) -> None: + """Test reconfigure flow with errors.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_nextdns.create.side_effect = exc + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["errors"] == {"base": base_error} + + mock_nextdns.create.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reconfigure_flow_no_profile( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test reconfigure flow when the profile is no longer available.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_nextdns_client.profiles = [ + ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile") + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "profile_not_available"