From 9cfdb99e7667e852c6efb5e1a121feef497fa50c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:52:12 +0200 Subject: [PATCH] Add repair to unsubscribe protected topic in ntfy integration (#152009) --- homeassistant/components/ntfy/event.py | 20 ++++++- .../components/ntfy/quality_scale.yaml | 4 +- homeassistant/components/ntfy/repairs.py | 56 +++++++++++++++++++ homeassistant/components/ntfy/strings.json | 13 +++++ tests/components/ntfy/test_event.py | 55 +++++++++++++++++- 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/ntfy/repairs.py diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index d961b67dcb85..8075e051ba4e 100644 --- a/homeassistant/components/ntfy/event.py +++ b/homeassistant/components/ntfy/event.py @@ -17,9 +17,17 @@ from aiontfy.exceptions import ( from homeassistant.components.event import EventEntity, EventEntityDescription from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_MESSAGE, CONF_PRIORITY, CONF_TAGS, CONF_TITLE +from .const import ( + CONF_MESSAGE, + CONF_PRIORITY, + CONF_TAGS, + CONF_TITLE, + CONF_TOPIC, + DOMAIN, +) from .coordinator import NtfyConfigEntry from .entity import NtfyBaseEntity @@ -100,6 +108,16 @@ class NtfyEventEntity(NtfyBaseEntity, EventEntity): if self._attr_available: _LOGGER.error("Failed to subscribe to topic. Topic is protected") self._attr_available = False + ir.async_create_issue( + self.hass, + DOMAIN, + f"topic_protected_{self.topic}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="topic_protected", + translation_placeholders={CONF_TOPIC: self.topic}, + data={"entity_id": self.entity_id, "topic": self.topic}, + ) return except NtfyHTTPError as e: if self._attr_available: diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 2e2a7910bba0..b00cdb93c970 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -63,9 +63,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: the integration has no repairs + repair-issues: done stale-devices: status: exempt comment: only one device per entry, is deleted with the entry. diff --git a/homeassistant/components/ntfy/repairs.py b/homeassistant/components/ntfy/repairs.py new file mode 100644 index 000000000000..e87ca3ddcad7 --- /dev/null +++ b/homeassistant/components/ntfy/repairs.py @@ -0,0 +1,56 @@ +"""Repairs for ntfy integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONF_TOPIC + + +class TopicProtectedRepairFlow(RepairsFlow): + """Handler for protected topic issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entity_id = data["entity_id"] + self.topic = data["topic"] + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Init repair flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Confirm repair flow.""" + if user_input is not None: + er.async_get(self.hass).async_update_entity( + self.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={CONF_TOPIC: self.topic}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str], +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("topic_protected"): + return TopicProtectedRepairFlow(data) + return ConfirmRepairFlow() diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 5066ce849d17..6bdcd1e0f9d7 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -354,5 +354,18 @@ "5": "Maximum" } } + }, + "issues": { + "topic_protected": { + "title": "Subscription failed: Topic {topic} is protected", + "fix_flow": { + "step": { + "confirm": { + "title": "Topic {topic} is protected", + "description": "The topic **{topic}** is protected and requires authentication to subscribe.\n\nTo resolve this issue, you have two options:\n\n1. **Reconfigure the ntfy integration**\nAdd a username and password that has permission to access this topic.\n\n2. **Deactivate the event entity**\nThis will stop Home Assistant from subscribing to the topic.\nClick **Submit** to deactivate the entity." + } + } + } + } } } diff --git a/tests/components/ntfy/test_event.py b/tests/components/ntfy/test_event.py index 92e01b1ba2c3..a71f45375d9a 100644 --- a/tests/components/ntfy/test_event.py +++ b/tests/components/ntfy/test_event.py @@ -17,12 +17,20 @@ from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ntfy.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -156,3 +164,48 @@ async def test_event_exceptions( assert (state := hass.states.get("event.mytopic")) assert state.state == expected_state + + +async def test_event_topic_protected( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test ntfy events cannot subscribe to protected topic.""" + mock_aiontfy.subscribe.side_effect = NtfyForbiddenError(403, 403, "forbidden") + + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "repairs", {}) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id="topic_protected_mytopic" + ) + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, "topic_protected_mytopic") + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + + assert (entity := entity_registry.async_get("event.mytopic")) + assert entity.disabled + assert entity.disabled_by is er.RegistryEntryDisabler.USER