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.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

View File

@ -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."""

View File

@ -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",

View File

@ -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

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": {
@ -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": {

View File

@ -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()
)

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."""
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"

View File

@ -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