Add reconfigure flow to ntfy integration (#143743)

This commit is contained in:
Manu 2025-06-20 18:42:47 +02:00 committed by GitHub
parent 6738085391
commit 9346c584c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 378 additions and 3 deletions

View File

@ -90,6 +90,24 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
}
)
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
{
vol.Exclusive(CONF_USERNAME, ATTR_CREDENTIALS): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT,
autocomplete="username",
),
),
vol.Optional(CONF_PASSWORD, default=""): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str,
}
)
STEP_USER_TOPIC_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOPIC): str,
@ -244,6 +262,103 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure flow for ntfy."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
if token := user_input.get(CONF_TOKEN):
ntfy = Ntfy(
entry.data[CONF_URL],
session,
token=user_input[CONF_TOKEN],
)
else:
ntfy = Ntfy(
entry.data[CONF_URL],
session,
username=user_input.get(CONF_USERNAME, entry.data[CONF_USERNAME]),
password=user_input[CONF_PASSWORD],
)
try:
account = await ntfy.account()
if not token:
token = (await ntfy.generate_token("Home Assistant")).token
except NtfyUnauthorizedAuthenticationError:
errors["base"] = "invalid_auth"
except NtfyHTTPError as e:
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
errors["base"] = "cannot_connect"
except NtfyException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if entry.data[CONF_USERNAME]:
if entry.data[CONF_USERNAME] != account.username:
return self.async_abort(
reason="account_mismatch",
description_placeholders={
CONF_USERNAME: entry.data[CONF_USERNAME],
"wrong_username": account.username,
},
)
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_TOKEN: token},
)
self._async_abort_entries_match(
{
CONF_URL: entry.data[CONF_URL],
CONF_USERNAME: account.username,
}
)
return self.async_update_reload_and_abort(
entry,
data_updates={
CONF_USERNAME: account.username,
CONF_TOKEN: token,
},
)
if entry.data[CONF_USERNAME]:
return self.async_show_form(
step_id="reconfigure_user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA,
suggested_values=user_input,
),
errors=errors,
description_placeholders={
CONF_NAME: entry.title,
CONF_USERNAME: entry.data[CONF_USERNAME],
},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
suggested_values=user_input,
),
errors=errors,
description_placeholders={CONF_NAME: entry.title},
)
async def async_step_reconfigure_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure flow for authenticated ntfy entry."""
return await self.async_step_reconfigure(user_input)
class TopicSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding and modifying a topic."""

View File

@ -72,7 +72,7 @@ rules:
comment: the notify entity uses the device name as entity name, no translation required
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: the integration has no repairs

View File

@ -39,7 +39,33 @@
},
"data_description": {
"password": "Enter the password corresponding to the aforementioned username to automatically create an access token",
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token"
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
}
},
"reconfigure": {
"title": "Configuration for {name}",
"description": "You can either log in with your **ntfy** username and password, and Home Assistant will automatically create an access token to authenticate with **ntfy**, or you can provide an access token directly",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"username": "[%key:component::ntfy::config::step::user::sections::auth::data_description::username%]",
"password": "[%key:component::ntfy::config::step::user::sections::auth::data_description::password%]",
"token": "Enter a new or existing access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
}
},
"reconfigure_user": {
"title": "[%key:component::ntfy::config::step::reconfigure::title%]",
"description": "Enter the password for **{username}** below. Home Assistant will automatically create a new access token to authenticate with **ntfy**. You can also directly provide a valid access token",
"data": {
"password": "[%key:common::config_flow::data::password%]",
"token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"password": "[%key:component::ntfy::config::step::reauth_confirm::data_description::password%]",
"token": "[%key:component::ntfy::config::step::reconfigure::data_description::token%]"
}
}
},
@ -51,7 +77,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**"
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"config_subentries": {

View File

@ -498,3 +498,236 @@ async def test_flow_reauth_account_mismatch(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "account_mismatch"
@pytest.mark.parametrize(
("entry_data", "user_input", "step_id"),
[
(
{CONF_USERNAME: None, CONF_TOKEN: None},
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
"reconfigure",
),
(
{CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"},
{CONF_TOKEN: "newtoken"},
"reconfigure_user",
),
],
)
async def test_flow_reconfigure(
hass: HomeAssistant,
mock_aiontfy: AsyncMock,
entry_data: dict[str, str | None],
user_input: dict[str, str],
step_id: str,
) -> None:
"""Test reconfigure flow."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
**entry_data,
},
)
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
token="newtoken", last_access=datetime.now()
)
config_entry.add_to_hass(hass)
result = await config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == step_id
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_USERNAME] == "username"
assert config_entry.data[CONF_TOKEN] == "newtoken"
assert len(hass.config_entries.async_entries()) == 1
@pytest.mark.parametrize(
("entry_data", "step_id"),
[
({CONF_USERNAME: None, CONF_TOKEN: None}, "reconfigure"),
({CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, "reconfigure_user"),
],
)
@pytest.mark.usefixtures("mock_aiontfy")
async def test_flow_reconfigure_token(
hass: HomeAssistant,
entry_data: dict[str, Any],
step_id: str,
) -> None:
"""Test reconfigure flow with access token."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
**entry_data,
},
)
config_entry.add_to_hass(hass)
result = await config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == step_id
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_TOKEN: "access_token"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_USERNAME] == "username"
assert config_entry.data[CONF_TOKEN] == "access_token"
assert len(hass.config_entries.async_entries()) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(
NtfyHTTPError(418001, 418, "I'm a teapot", ""),
"cannot_connect",
),
(
NtfyUnauthorizedAuthenticationError(
40101,
401,
"unauthorized",
"https://ntfy.sh/docs/publish/#authentication",
),
"invalid_auth",
),
(NtfyException, "cannot_connect"),
(TypeError, "unknown"),
],
)
async def test_flow_reconfigure_errors(
hass: HomeAssistant,
mock_aiontfy: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reconfigure flow errors."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: None,
CONF_TOKEN: None,
},
)
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
token="newtoken", last_access=datetime.now()
)
mock_aiontfy.account.side_effect = exception
config_entry.add_to_hass(hass)
result = await 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"],
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_aiontfy.account.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_USERNAME] == "username"
assert config_entry.data[CONF_TOKEN] == "newtoken"
assert len(hass.config_entries.async_entries()) == 1
@pytest.mark.usefixtures("mock_aiontfy")
async def test_flow_reconfigure_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow already configured."""
other_config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
},
)
other_config_entry.add_to_hass(hass)
config_entry.add_to_hass(hass)
result = await 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"],
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(hass.config_entries.async_entries()) == 2
@pytest.mark.usefixtures("mock_aiontfy")
async def test_flow_reconfigure_account_mismatch(
hass: HomeAssistant,
) -> None:
"""Test reconfigure flow account mismatch."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "wrong_username",
CONF_TOKEN: "oldtoken",
},
)
config_entry.add_to_hass(hass)
result = await config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_TOKEN: "newtoken"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "account_mismatch"