Add reauth flow to ntfy integration (#143729)

This commit is contained in:
Manu 2025-04-26 22:05:13 +02:00 committed by GitHub
parent 35c6fdbce8
commit a0cd14b4e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 327 additions and 14 deletions

View File

@ -15,7 +15,7 @@ from aiontfy.exceptions import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, Platform from homeassistant.const import CONF_TOKEN, CONF_URL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool
try: try:
await ntfy.account() await ntfy.account()
except NtfyUnauthorizedAuthenticationError as e: except NtfyUnauthorizedAuthenticationError as e:
raise ConfigEntryNotReady( raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="authentication_error", translation_key="authentication_error",
) from e ) from e

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
import random import random
import re import re
@ -26,6 +27,7 @@ from homeassistant.config_entries import (
SubentryFlowResult, SubentryFlowResult,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_CREDENTIALS,
CONF_NAME, CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_TOKEN, CONF_TOKEN,
@ -74,6 +76,18 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Exclusive(CONF_PASSWORD, ATTR_CREDENTIALS): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str,
}
)
STEP_USER_TOPIC_SCHEMA = vol.Schema( STEP_USER_TOPIC_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_TOPIC): str, vol.Required(CONF_TOPIC): str,
@ -157,6 +171,76 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
entry = self._get_reauth_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=entry.data[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
account = await ntfy.account()
token = (
(await ntfy.generate_token("Home Assistant")).token
if not user_input.get(CONF_TOKEN)
else user_input[CONF_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] != 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},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
)
class TopicSubentryFlowHandler(ConfigSubentryFlow): class TopicSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding and modifying a topic.""" """Handle subentry flow for adding and modifying a topic."""

View File

@ -3,7 +3,11 @@
from __future__ import annotations from __future__ import annotations
from aiontfy import Message from aiontfy import Message
from aiontfy.exceptions import NtfyException, NtfyHTTPError from aiontfy.exceptions import (
NtfyException,
NtfyHTTPError,
NtfyUnauthorizedAuthenticationError,
)
from yarl import URL from yarl import URL
from homeassistant.components.notify import ( from homeassistant.components.notify import (
@ -66,6 +70,7 @@ class NtfyNotifyEntity(NotifyEntity):
configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
) )
self.config_entry = config_entry
self.ntfy = config_entry.runtime_data self.ntfy = config_entry.runtime_data
async def async_send_message(self, message: str, title: str | None = None) -> None: async def async_send_message(self, message: str, title: str | None = None) -> None:
@ -73,6 +78,12 @@ class NtfyNotifyEntity(NotifyEntity):
msg = Message(topic=self.topic, message=message, title=title) msg = Message(topic=self.topic, message=message, title=title)
try: try:
await self.ntfy.publish(msg) await self.ntfy.publish(msg)
except NtfyUnauthorizedAuthenticationError as e:
self.config_entry.async_start_reauth(self.hass)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
except NtfyHTTPError as e: except NtfyHTTPError as e:
raise HomeAssistantError( raise HomeAssistantError(
translation_key="publish_failed_request_error", translation_key="publish_failed_request_error",

View File

@ -44,7 +44,7 @@ rules:
status: exempt status: exempt
comment: the integration only integrates state-less entities comment: the integration only integrates state-less entities
parallel-updates: done parallel-updates: done
reauthentication-flow: todo reauthentication-flow: done
test-coverage: done test-coverage: done
# Gold # Gold

View File

@ -27,6 +27,18 @@
} }
} }
} }
},
"reauth_confirm": {
"title": "Re-authenticate with ntfy ({name})",
"description": "The access token for **{username}** is invalid. To re-authenticate with the ntfy service, you can either log in with your password (a new access token will be created automatically) or you can 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": "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"
}
} }
}, },
"error": { "error": {
@ -35,7 +47,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "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 with the account **{username}**"
} }
}, },
"config_subentries": { "config_subentries": {

View File

@ -4,14 +4,14 @@ from collections.abc import Generator
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from aiontfy import AccountTokenResponse from aiontfy import Account, AccountTokenResponse
import pytest import pytest
from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN
from homeassistant.config_entries import ConfigSubentryData from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, load_fixture
@pytest.fixture @pytest.fixture
@ -34,6 +34,9 @@ def mock_aiontfy() -> Generator[AsyncMock]:
client = mock_client.return_value client = mock_client.return_value
client.publish.return_value = {} client.publish.return_value = {}
client.account.return_value = Account.from_json(
load_fixture("account.json", DOMAIN)
)
client.generate_token.return_value = AccountTokenResponse( client.generate_token.return_value = AccountTokenResponse(
token="token", last_access=datetime.now() token="token", last_access=datetime.now()
) )

View File

@ -0,0 +1,59 @@
{
"username": "username",
"role": "user",
"sync_topic": "st_xxxxxxxxxxxxx",
"language": "en",
"notification": {
"min_priority": 2,
"delete_after": 604800
},
"subscriptions": [
{
"base_url": "http://localhost",
"topic": "test",
"display_name": null
}
],
"reservations": [
{
"topic": "test",
"everyone": "read-only"
}
],
"tokens": [
{
"token": "tk_xxxxxxxxxxxxxxxxxxxxxxxxxx",
"last_access": 1743362634,
"last_origin": "172.17.0.1",
"expires": 1743621234
}
],
"tier": {
"code": "starter",
"name": "starter"
},
"limits": {
"basis": "tier",
"messages": 5000,
"messages_expiry_duration": 43200,
"emails": 20,
"calls": 0,
"reservations": 3,
"attachment_total_size": 104857600,
"attachment_file_size": 15728640,
"attachment_expiry_duration": 21600,
"attachment_bandwidth": 1073741824
},
"stats": {
"messages": 10,
"messages_remaining": 4990,
"emails": 0,
"emails_remaining": 20,
"calls": 0,
"calls_remaining": 0,
"reservations": 1,
"reservations_remaining": 2,
"attachment_total_size": 0,
"attachment_total_size_remaining": 104857600
}
}

View File

@ -1,8 +1,10 @@
"""Test the ntfy config flow.""" """Test the ntfy config flow."""
from datetime import datetime
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aiontfy import AccountTokenResponse
from aiontfy.exceptions import ( from aiontfy.exceptions import (
NtfyException, NtfyException,
NtfyHTTPError, NtfyHTTPError,
@ -348,3 +350,136 @@ async def test_topic_already_configured(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"user_input", [{CONF_PASSWORD: "password"}, {CONF_TOKEN: "newtoken"}]
)
@pytest.mark.usefixtures("mock_aiontfy")
async def test_flow_reauth(
hass: HomeAssistant,
mock_aiontfy: AsyncMock,
user_input: dict[str, Any],
) -> None:
"""Test reauth flow."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
CONF_TOKEN: "token",
},
)
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
token="newtoken", last_access=datetime.now()
)
config_entry.add_to_hass(hass)
result = await 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,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert config_entry.data[CONF_TOKEN] == "newtoken"
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_form_reauth_errors(
hass: HomeAssistant,
mock_aiontfy: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reauth flow errors."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="ntfy.sh",
data={
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
CONF_TOKEN: "token",
},
)
mock_aiontfy.account.side_effect = exception
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
token="newtoken", last_access=datetime.now()
)
config_entry.add_to_hass(hass)
result = await 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"], {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_PASSWORD: "password"}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert config_entry.data == {
CONF_URL: "https://ntfy.sh/",
CONF_USERNAME: "username",
CONF_TOKEN: "newtoken",
}
assert len(hass.config_entries.async_entries()) == 1
@pytest.mark.usefixtures("mock_aiontfy")
async def test_flow_reauth_account_mismatch(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow."""
config_entry.add_to_hass(hass)
result = await 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"],
{CONF_TOKEN: "newtoken"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "account_mismatch"

View File

@ -34,14 +34,20 @@ async def test_entry_setup_unload(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception"), ("exception", "state"),
[ [
NtfyUnauthorizedAuthenticationError( (
40101, 401, "unauthorized", "https://ntfy.sh/docs/publish/#authentication" NtfyUnauthorizedAuthenticationError(
40101,
401,
"unauthorized",
"https://ntfy.sh/docs/publish/#authentication",
),
ConfigEntryState.SETUP_ERROR,
), ),
NtfyHTTPError(418001, 418, "I'm a teapot", ""), (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY),
NtfyConnectionError, (NtfyConnectionError, ConfigEntryState.SETUP_RETRY),
NtfyTimeoutError, (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY),
], ],
) )
async def test_config_entry_not_ready( async def test_config_entry_not_ready(
@ -49,6 +55,7 @@ async def test_config_entry_not_ready(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_aiontfy: AsyncMock, mock_aiontfy: AsyncMock,
exception: Exception, exception: Exception,
state: ConfigEntryState,
) -> None: ) -> None:
"""Test config entry not ready.""" """Test config entry not ready."""
@ -57,4 +64,4 @@ async def test_config_entry_not_ready(
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.state is state