diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index f51e5d5a0e1..44a8a7e00d9 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -15,7 +15,7 @@ from aiontfy.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL, Platform 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 .const import DOMAIN @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool try: await ntfy.account() except NtfyUnauthorizedAuthenticationError as e: - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index cc4bcbf14ba..ffbb1c762ed 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging import random import re @@ -26,6 +27,7 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.const import ( + ATTR_CREDENTIALS, CONF_NAME, CONF_PASSWORD, 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( { vol.Required(CONF_TOPIC): str, @@ -157,6 +171,76 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): 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): """Handle subentry flow for adding and modifying a topic.""" diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index ac06e430346..7328a1533c2 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -3,7 +3,11 @@ from __future__ import annotations from aiontfy import Message -from aiontfy.exceptions import NtfyException, NtfyHTTPError +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) from yarl import URL from homeassistant.components.notify import ( @@ -66,6 +70,7 @@ class NtfyNotifyEntity(NotifyEntity): configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, ) + self.config_entry = config_entry self.ntfy = config_entry.runtime_data 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) try: 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: raise HomeAssistantError( translation_key="publish_failed_request_error", diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index d476981cf6a..84ec1d82b7c 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -44,7 +44,7 @@ rules: status: exempt comment: the integration only integrates state-less entities parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 21b1fb22200..c60f618ed66 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -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": { @@ -35,7 +47,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "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": { diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index b0279dff2ad..52d6e413c4e 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -4,14 +4,14 @@ from collections.abc import Generator from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch -from aiontfy import AccountTokenResponse +from aiontfy import Account, AccountTokenResponse import pytest from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -34,6 +34,9 @@ def mock_aiontfy() -> Generator[AsyncMock]: client = mock_client.return_value client.publish.return_value = {} + client.account.return_value = Account.from_json( + load_fixture("account.json", DOMAIN) + ) client.generate_token.return_value = AccountTokenResponse( token="token", last_access=datetime.now() ) diff --git a/tests/components/ntfy/fixtures/account.json b/tests/components/ntfy/fixtures/account.json new file mode 100644 index 00000000000..8b4ee501a4d --- /dev/null +++ b/tests/components/ntfy/fixtures/account.json @@ -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 + } +} diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 9e719eff154..e846b805298 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -1,8 +1,10 @@ """Test the ntfy config flow.""" +from datetime import datetime from typing import Any from unittest.mock import AsyncMock +from aiontfy import AccountTokenResponse from aiontfy.exceptions import ( NtfyException, NtfyHTTPError, @@ -348,3 +350,136 @@ async def test_topic_already_configured( assert result["type"] is FlowResultType.ABORT 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" diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py index 2ee90854426..b80badd8581 100644 --- a/tests/components/ntfy/test_init.py +++ b/tests/components/ntfy/test_init.py @@ -34,14 +34,20 @@ async def test_entry_setup_unload( @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", ""), - NtfyConnectionError, - NtfyTimeoutError, + (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY), + (NtfyConnectionError, ConfigEntryState.SETUP_RETRY), + (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY), ], ) async def test_config_entry_not_ready( @@ -49,6 +55,7 @@ async def test_config_entry_not_ready( config_entry: MockConfigEntry, mock_aiontfy: AsyncMock, exception: Exception, + state: ConfigEntryState, ) -> None: """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.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is state