Skip to content

Commit f88876a

Browse files
authored
Add reauth flow to Namecheap DynamicDNS integration (home-assistant#161674)
1 parent de834f9 commit f88876a

5 files changed

Lines changed: 188 additions & 9 deletions

File tree

homeassistant/components/namecheapdns/config_flow.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
import logging
67
from typing import Any
78

89
from aiohttp import ClientError
910
import voluptuous as vol
1011

11-
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
12+
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
1213
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_NAME, CONF_PASSWORD
1314
from homeassistant.helpers import config_validation as cv
1415
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -19,7 +20,7 @@
1920
)
2021

2122
from .const import DOMAIN
22-
from .helpers import update_namecheapdns
23+
from .helpers import AuthFailed, update_namecheapdns
2324
from .issue import deprecate_yaml_issue
2425

2526
_LOGGER = logging.getLogger(__name__)
@@ -100,13 +101,29 @@ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResu
100101
deprecate_yaml_issue(self.hass, import_success=True)
101102
return result
102103

104+
async def async_step_reauth(
105+
self, entry_data: Mapping[str, Any]
106+
) -> ConfigFlowResult:
107+
"""Perform reauth upon authentication error."""
108+
return await self.async_step_reauth_confirm()
109+
103110
async def async_step_reconfigure(
104111
self, user_input: dict[str, Any] | None = None
105112
) -> ConfigFlowResult:
106113
"""Handle reconfigure flow."""
114+
return await self.async_step_reauth_confirm(user_input)
115+
116+
async def async_step_reauth_confirm(
117+
self, user_input: dict[str, Any] | None = None
118+
) -> ConfigFlowResult:
119+
"""Confirm reauthentication dialog."""
107120
errors: dict[str, str] = {}
108121

109-
entry = self._get_reconfigure_entry()
122+
entry = (
123+
self._get_reauth_entry()
124+
if self.source == SOURCE_REAUTH
125+
else self._get_reconfigure_entry()
126+
)
110127

111128
if user_input is not None:
112129
session = async_get_clientsession(self.hass)
@@ -118,6 +135,8 @@ async def async_step_reconfigure(
118135
user_input[CONF_PASSWORD],
119136
):
120137
errors["base"] = "update_failed"
138+
except AuthFailed:
139+
errors["base"] = "invalid_auth"
121140
except ClientError:
122141
_LOGGER.debug("Cannot connect", exc_info=True)
123142
errors["base"] = "cannot_connect"
@@ -132,8 +151,12 @@ async def async_step_reconfigure(
132151
)
133152

134153
return self.async_show_form(
135-
step_id="reconfigure",
154+
step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure",
136155
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
137156
errors=errors,
138-
description_placeholders={CONF_NAME: entry.title},
157+
description_placeholders={
158+
"account_panel": f"https://ap.www.namecheap.com/Domains/DomainControlPanel/{entry.data[CONF_DOMAIN]}/advancedns",
159+
CONF_NAME: entry.title,
160+
CONF_DOMAIN: entry.data[CONF_DOMAIN],
161+
},
139162
)

homeassistant/components/namecheapdns/coordinator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
from homeassistant.config_entries import ConfigEntry
99
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
1010
from homeassistant.core import HomeAssistant
11+
from homeassistant.exceptions import ConfigEntryAuthFailed
1112
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1213
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1314

1415
from .const import DOMAIN
15-
from .helpers import update_namecheapdns
16+
from .helpers import AuthFailed, update_namecheapdns
1617

1718
_LOGGER = logging.getLogger(__name__)
1819

@@ -53,6 +54,12 @@ async def _async_update_data(self) -> None:
5354
translation_key="update_failed",
5455
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
5556
)
57+
except AuthFailed as e:
58+
raise ConfigEntryAuthFailed(
59+
translation_domain=DOMAIN,
60+
translation_key="authentication_failed",
61+
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
62+
) from e
5663
except ClientError as e:
5764
raise UpdateFailed(
5865
translation_domain=DOMAIN,

homeassistant/components/namecheapdns/helpers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,29 @@
44

55
from aiohttp import ClientSession
66

7+
from homeassistant.exceptions import HomeAssistantError
8+
79
from .const import UPDATE_URL
810

911
_LOGGER = logging.getLogger(__name__)
1012

1113

1214
async def update_namecheapdns(
1315
session: ClientSession, host: str, domain: str, password: str
14-
):
16+
) -> bool:
1517
"""Update namecheap DNS entry."""
1618
params = {"host": host, "domain": domain, "password": password}
1719

1820
resp = await session.get(UPDATE_URL, params=params)
1921
xml_string = await resp.text()
2022

2123
if "<ErrCount>0</ErrCount>" not in xml_string:
24+
if "<Err1>Passwords do not match</Err1>" in xml_string:
25+
raise AuthFailed
2226
return False
2327

2428
return True
29+
30+
31+
class AuthFailed(HomeAssistantError):
32+
"""Authentication error."""

homeassistant/components/namecheapdns/strings.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,34 @@
22
"config": {
33
"abort": {
44
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
5+
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
56
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
67
},
78
"error": {
89
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
10+
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
911
"unknown": "[%key:common::config_flow::error::unknown%]",
1012
"update_failed": "Updating DNS failed"
1113
},
1214
"step": {
15+
"reauth_confirm": {
16+
"data": {
17+
"password": "[%key:component::namecheapdns::config::step::user::data::password%]"
18+
},
19+
"data_description": {
20+
"password": "[%key:component::namecheapdns::config::step::user::data_description::password%]"
21+
},
22+
"description": "[%key:component::namecheapdns::config::step::reconfigure::description%]",
23+
"title": "Re-authenticate {name}"
24+
},
1325
"reconfigure": {
1426
"data": {
1527
"password": "[%key:component::namecheapdns::config::step::user::data::password%]"
1628
},
1729
"data_description": {
1830
"password": "[%key:component::namecheapdns::config::step::user::data_description::password%]"
1931
},
32+
"description": "You can find the Dynamic DNS password in your Namecheap account under [Domain List > {domain} > Manage > Advanced DNS > Dynamic DNS]({account_panel}).",
2033
"title": "Re-configure {name}"
2134
},
2235
"user": {
@@ -35,6 +48,9 @@
3548
}
3649
},
3750
"exceptions": {
51+
"authentication_failed": {
52+
"message": "Authentication for Namecheap DynamicDNS domain {domain} failed"
53+
},
3854
"connection_error": {
3955
"message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error"
4056
},

tests/components/namecheapdns/test_config_flow.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
from aiohttp import ClientError
66
import pytest
77

8-
from homeassistant.components.namecheapdns.const import DOMAIN
9-
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
8+
from homeassistant.components.namecheapdns.const import DOMAIN, UPDATE_URL
9+
from homeassistant.components.namecheapdns.helpers import AuthFailed
10+
from homeassistant.config_entries import (
11+
SOURCE_IMPORT,
12+
SOURCE_REAUTH,
13+
SOURCE_USER,
14+
ConfigEntryState,
15+
)
1016
from homeassistant.const import CONF_PASSWORD
1117
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
1218
from homeassistant.data_entry_flow import FlowResultType
@@ -16,6 +22,7 @@
1622
from .conftest import TEST_USER_INPUT
1723

1824
from tests.common import MockConfigEntry
25+
from tests.test_util.aiohttp import AiohttpClientMocker
1926

2027

2128
@pytest.mark.usefixtures("mock_namecheap")
@@ -173,6 +180,7 @@ async def test_reconfigure(
173180
(ValueError, "unknown"),
174181
(False, "update_failed"),
175182
(ClientError, "cannot_connect"),
183+
(AuthFailed, "invalid_auth"),
176184
],
177185
)
178186
async def test_reconfigure_errors(
@@ -208,3 +216,120 @@ async def test_reconfigure_errors(
208216
assert result["reason"] == "reconfigure_successful"
209217

210218
assert config_entry.data[CONF_PASSWORD] == "new-password"
219+
220+
221+
@pytest.mark.usefixtures("mock_namecheap")
222+
async def test_reauth(
223+
hass: HomeAssistant,
224+
config_entry: MockConfigEntry,
225+
aioclient_mock: AiohttpClientMocker,
226+
) -> None:
227+
"""Test reauth flow."""
228+
aioclient_mock.get(
229+
UPDATE_URL,
230+
params=TEST_USER_INPUT,
231+
text="<interface-response><ErrCount>0</ErrCount></interface-response>",
232+
)
233+
config_entry.add_to_hass(hass)
234+
assert await hass.config_entries.async_setup(config_entry.entry_id)
235+
await hass.async_block_till_done()
236+
237+
assert config_entry.state is ConfigEntryState.LOADED
238+
239+
result = await config_entry.start_reauth_flow(hass)
240+
241+
assert result["type"] is FlowResultType.FORM
242+
assert result["step_id"] == "reauth_confirm"
243+
244+
result = await hass.config_entries.flow.async_configure(
245+
result["flow_id"], {CONF_PASSWORD: "new-password"}
246+
)
247+
await hass.async_block_till_done()
248+
249+
assert result["type"] is FlowResultType.ABORT
250+
assert result["reason"] == "reauth_successful"
251+
assert config_entry.data[CONF_PASSWORD] == "new-password"
252+
253+
254+
@pytest.mark.parametrize(
255+
("side_effect", "text_error"),
256+
[
257+
(ValueError, "unknown"),
258+
(False, "update_failed"),
259+
(ClientError, "cannot_connect"),
260+
(AuthFailed, "invalid_auth"),
261+
],
262+
)
263+
async def test_reauth_errors(
264+
hass: HomeAssistant,
265+
config_entry: MockConfigEntry,
266+
mock_namecheap: AsyncMock,
267+
side_effect: Exception | bool,
268+
text_error: str,
269+
aioclient_mock: AiohttpClientMocker,
270+
) -> None:
271+
"""Test we handle errors."""
272+
aioclient_mock.get(
273+
UPDATE_URL,
274+
params=TEST_USER_INPUT,
275+
text="<interface-response><ErrCount>0</ErrCount></interface-response>",
276+
)
277+
config_entry.add_to_hass(hass)
278+
assert await hass.config_entries.async_setup(config_entry.entry_id)
279+
await hass.async_block_till_done()
280+
281+
assert config_entry.state is ConfigEntryState.LOADED
282+
283+
result = await config_entry.start_reauth_flow(hass)
284+
285+
assert result["type"] is FlowResultType.FORM
286+
assert result["step_id"] == "reauth_confirm"
287+
288+
mock_namecheap.side_effect = [side_effect]
289+
result = await hass.config_entries.flow.async_configure(
290+
result["flow_id"], {CONF_PASSWORD: "new-password"}
291+
)
292+
293+
assert result["type"] is FlowResultType.FORM
294+
assert result["errors"] == {"base": text_error}
295+
296+
mock_namecheap.side_effect = None
297+
298+
result = await hass.config_entries.flow.async_configure(
299+
result["flow_id"], {CONF_PASSWORD: "new-password"}
300+
)
301+
302+
assert result["type"] is FlowResultType.ABORT
303+
assert result["reason"] == "reauth_successful"
304+
305+
assert config_entry.data[CONF_PASSWORD] == "new-password"
306+
307+
308+
async def test_initiate_reauth_flow(
309+
hass: HomeAssistant,
310+
config_entry: MockConfigEntry,
311+
aioclient_mock: AiohttpClientMocker,
312+
) -> None:
313+
"""Test authentication error initiates reauth flow."""
314+
315+
aioclient_mock.get(
316+
UPDATE_URL,
317+
params=TEST_USER_INPUT,
318+
text="<interface-response><ErrCount>1</ErrCount><errors><Err1>Passwords do not match</Err1></errors></interface-response>",
319+
)
320+
config_entry.add_to_hass(hass)
321+
await hass.config_entries.async_setup(config_entry.entry_id)
322+
await hass.async_block_till_done()
323+
324+
assert config_entry.state is ConfigEntryState.SETUP_ERROR
325+
326+
flows = hass.config_entries.flow.async_progress()
327+
assert len(flows) == 1
328+
329+
flow = flows[0]
330+
assert flow.get("step_id") == "reauth_confirm"
331+
assert flow.get("handler") == DOMAIN
332+
333+
assert "context" in flow
334+
assert flow["context"].get("source") == SOURCE_REAUTH
335+
assert flow["context"].get("entry_id") == config_entry.entry_id

0 commit comments

Comments
 (0)