mirror of
https://github.com/home-assistant/core.git
synced 2025-06-05 21:57:08 +00:00
Add reauth flow to ntfy integration (#143729)
This commit is contained in:
parent
35c6fdbce8
commit
a0cd14b4e8
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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()
|
||||
)
|
||||
|
59
tests/components/ntfy/fixtures/account.json
Normal file
59
tests/components/ntfy/fixtures/account.json
Normal 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
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user