Skip to content

Commit 7bceaf7

Browse files
authored
Support reconfigure flow in NextDNS integration (home-assistant#154936)
1 parent 750f063 commit 7bceaf7

File tree

5 files changed

+212
-20
lines changed

5 files changed

+212
-20
lines changed

homeassistant/components/nextdns/config_flow.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
1515
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME
1616
from homeassistant.core import HomeAssistant
17+
from homeassistant.exceptions import HomeAssistantError
1718
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1819

1920
from .const import CONF_PROFILE_ID, DOMAIN
@@ -23,11 +24,40 @@
2324
_LOGGER = logging.getLogger(__name__)
2425

2526

26-
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
27-
"""Check if credentials are valid."""
27+
async def async_init_nextdns(
28+
hass: HomeAssistant, api_key: str, profile_id: str | None = None
29+
) -> NextDns:
30+
"""Check if credentials and profile_id are valid."""
2831
websession = async_get_clientsession(hass)
2932

30-
return await NextDns.create(websession, api_key)
33+
nextdns = await NextDns.create(websession, api_key)
34+
35+
if profile_id:
36+
if not any(profile.id == profile_id for profile in nextdns.profiles):
37+
raise ProfileNotAvailable
38+
39+
return nextdns
40+
41+
42+
async def async_validate_new_api_key(
43+
hass: HomeAssistant, user_input: dict[str, Any], profile_id: str
44+
) -> dict[str, str]:
45+
"""Validate the new API key during reconfiguration or reauth."""
46+
errors: dict[str, str] = {}
47+
48+
try:
49+
await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id)
50+
except InvalidApiKeyError:
51+
errors["base"] = "invalid_api_key"
52+
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
53+
errors["base"] = "cannot_connect"
54+
except ProfileNotAvailable:
55+
errors["base"] = "profile_not_available"
56+
except Exception:
57+
_LOGGER.exception("Unexpected exception")
58+
errors["base"] = "unknown"
59+
60+
return errors
3161

3262

3363
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -107,24 +137,53 @@ async def async_step_reauth_confirm(
107137
) -> ConfigFlowResult:
108138
"""Dialog that informs the user that reauth is required."""
109139
errors: dict[str, str] = {}
140+
entry = self._get_reauth_entry()
110141

111142
if user_input is not None:
112-
try:
113-
await async_init_nextdns(self.hass, user_input[CONF_API_KEY])
114-
except InvalidApiKeyError:
115-
errors["base"] = "invalid_api_key"
116-
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
117-
errors["base"] = "cannot_connect"
118-
except Exception:
119-
_LOGGER.exception("Unexpected exception")
120-
errors["base"] = "unknown"
121-
else:
143+
errors = await async_validate_new_api_key(
144+
self.hass, user_input, entry.data[CONF_PROFILE_ID]
145+
)
146+
if errors.get("base") == "profile_not_available":
147+
return self.async_abort(reason="profile_not_available")
148+
149+
if not errors:
122150
return self.async_update_reload_and_abort(
123-
self._get_reauth_entry(), data_updates=user_input
151+
entry,
152+
data_updates=user_input,
124153
)
125154

126155
return self.async_show_form(
127156
step_id="reauth_confirm",
128157
data_schema=AUTH_SCHEMA,
129158
errors=errors,
130159
)
160+
161+
async def async_step_reconfigure(
162+
self, user_input: dict[str, Any] | None = None
163+
) -> ConfigFlowResult:
164+
"""Handle a reconfiguration flow initialized by the user."""
165+
errors: dict[str, str] = {}
166+
entry = self._get_reconfigure_entry()
167+
168+
if user_input is not None:
169+
errors = await async_validate_new_api_key(
170+
self.hass, user_input, entry.data[CONF_PROFILE_ID]
171+
)
172+
if errors.get("base") == "profile_not_available":
173+
return self.async_abort(reason="profile_not_available")
174+
175+
if not errors:
176+
return self.async_update_reload_and_abort(
177+
entry,
178+
data_updates=user_input,
179+
)
180+
181+
return self.async_show_form(
182+
step_id="reconfigure",
183+
data_schema=AUTH_SCHEMA,
184+
errors=errors,
185+
)
186+
187+
188+
class ProfileNotAvailable(HomeAssistantError):
189+
"""Error to indicate that the profile is not available after reconfig/reauth."""

homeassistant/components/nextdns/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
"integration_type": "service",
88
"iot_class": "cloud_polling",
99
"loggers": ["nextdns"],
10-
"quality_scale": "silver",
10+
"quality_scale": "platinum",
1111
"requirements": ["nextdns==4.1.0"]
1212
}

homeassistant/components/nextdns/quality_scale.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,7 @@ rules:
6868
entity-translations: done
6969
exception-translations: done
7070
icon-translations: done
71-
reconfiguration-flow:
72-
status: todo
73-
comment: Allow API key to be changed in the re-configure flow.
71+
reconfiguration-flow: done
7472
repair-issues:
7573
status: exempt
7674
comment: This integration doesn't have any cases where raising an issue is needed.

homeassistant/components/nextdns/strings.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
"data_description": {
2525
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
2626
}
27+
},
28+
"reconfigure": {
29+
"data": {
30+
"api_key": "[%key:common::config_flow::data::api_key%]"
31+
},
32+
"data_description": {
33+
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
34+
}
2735
}
2836
},
2937
"error": {
@@ -33,7 +41,9 @@
3341
},
3442
"abort": {
3543
"already_configured": "This NextDNS profile is already configured.",
36-
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
44+
"profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.",
45+
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
46+
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
3747
}
3848
},
3949
"system_health": {

tests/components/nextdns/test_config_flow.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from unittest.mock import AsyncMock
44

5-
from nextdns import ApiError, InvalidApiKeyError
5+
from nextdns import ApiError, InvalidApiKeyError, ProfileInfo
66
import pytest
77
from tenacity import RetryError
88

@@ -154,6 +154,32 @@ async def test_reauth_successful(
154154

155155
assert result["type"] is FlowResultType.ABORT
156156
assert result["reason"] == "reauth_successful"
157+
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
158+
159+
160+
async def test_reauth_no_profile(
161+
hass: HomeAssistant,
162+
mock_config_entry: MockConfigEntry,
163+
mock_nextdns_client: AsyncMock,
164+
) -> None:
165+
"""Test reauthentication flow when the profile is no longer available."""
166+
await init_integration(hass, mock_config_entry)
167+
168+
result = await mock_config_entry.start_reauth_flow(hass)
169+
assert result["type"] is FlowResultType.FORM
170+
assert result["step_id"] == "reauth_confirm"
171+
172+
mock_nextdns_client.profiles = [
173+
ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile")
174+
]
175+
176+
result = await hass.config_entries.flow.async_configure(
177+
result["flow_id"],
178+
user_input={CONF_API_KEY: "new_api_key"},
179+
)
180+
181+
assert result["type"] is FlowResultType.ABORT
182+
assert result["reason"] == "profile_not_available"
157183

158184

159185
@pytest.mark.parametrize(
@@ -196,6 +222,105 @@ async def test_reauth_errors(
196222
result["flow_id"],
197223
user_input={CONF_API_KEY: "new_api_key"},
198224
)
225+
await hass.async_block_till_done()
199226

200227
assert result["type"] is FlowResultType.ABORT
201228
assert result["reason"] == "reauth_successful"
229+
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
230+
231+
232+
async def test_reconfigure_flow(
233+
hass: HomeAssistant,
234+
mock_config_entry: MockConfigEntry,
235+
mock_nextdns_client: AsyncMock,
236+
) -> None:
237+
"""Test starting a reconfigure flow."""
238+
await init_integration(hass, mock_config_entry)
239+
240+
result = await mock_config_entry.start_reconfigure_flow(hass)
241+
242+
assert result["type"] is FlowResultType.FORM
243+
assert result["step_id"] == "reconfigure"
244+
245+
result = await hass.config_entries.flow.async_configure(
246+
result["flow_id"],
247+
user_input={CONF_API_KEY: "new_api_key"},
248+
)
249+
250+
assert result["type"] is FlowResultType.ABORT
251+
assert result["reason"] == "reconfigure_successful"
252+
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
253+
254+
255+
@pytest.mark.parametrize(
256+
("exc", "base_error"),
257+
[
258+
(ApiError("API Error"), "cannot_connect"),
259+
(InvalidApiKeyError, "invalid_api_key"),
260+
(RetryError("Retry Error"), "cannot_connect"),
261+
(TimeoutError, "cannot_connect"),
262+
(ValueError, "unknown"),
263+
],
264+
)
265+
async def test_reconfiguration_errors(
266+
hass: HomeAssistant,
267+
exc: Exception,
268+
base_error: str,
269+
mock_config_entry: MockConfigEntry,
270+
mock_nextdns_client: AsyncMock,
271+
mock_nextdns: AsyncMock,
272+
) -> None:
273+
"""Test reconfigure flow with errors."""
274+
await init_integration(hass, mock_config_entry)
275+
276+
result = await mock_config_entry.start_reconfigure_flow(hass)
277+
278+
assert result["type"] is FlowResultType.FORM
279+
assert result["step_id"] == "reconfigure"
280+
281+
mock_nextdns.create.side_effect = exc
282+
283+
result = await hass.config_entries.flow.async_configure(
284+
result["flow_id"],
285+
user_input={CONF_API_KEY: "new_api_key"},
286+
)
287+
288+
assert result["errors"] == {"base": base_error}
289+
290+
mock_nextdns.create.side_effect = None
291+
292+
result = await hass.config_entries.flow.async_configure(
293+
result["flow_id"],
294+
user_input={CONF_API_KEY: "new_api_key"},
295+
)
296+
await hass.async_block_till_done()
297+
298+
assert result["type"] is FlowResultType.ABORT
299+
assert result["reason"] == "reconfigure_successful"
300+
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
301+
302+
303+
async def test_reconfigure_flow_no_profile(
304+
hass: HomeAssistant,
305+
mock_config_entry: MockConfigEntry,
306+
mock_nextdns_client: AsyncMock,
307+
) -> None:
308+
"""Test reconfigure flow when the profile is no longer available."""
309+
await init_integration(hass, mock_config_entry)
310+
311+
result = await mock_config_entry.start_reconfigure_flow(hass)
312+
313+
assert result["type"] is FlowResultType.FORM
314+
assert result["step_id"] == "reconfigure"
315+
316+
mock_nextdns_client.profiles = [
317+
ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile")
318+
]
319+
320+
result = await hass.config_entries.flow.async_configure(
321+
result["flow_id"],
322+
user_input={CONF_API_KEY: "new_api_key"},
323+
)
324+
325+
assert result["type"] is FlowResultType.ABORT
326+
assert result["reason"] == "profile_not_available"

0 commit comments

Comments
 (0)