Skip to content

Commit 9cfdb99

Browse files
authored
Add repair to unsubscribe protected topic in ntfy integration (home-assistant#152009)
1 parent b1a6e40 commit 9cfdb99

File tree

5 files changed

+143
-5
lines changed

5 files changed

+143
-5
lines changed

homeassistant/components/ntfy/event.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@
1717
from homeassistant.components.event import EventEntity, EventEntityDescription
1818
from homeassistant.config_entries import ConfigSubentry
1919
from homeassistant.core import HomeAssistant, callback
20+
from homeassistant.helpers import issue_registry as ir
2021
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
2122

22-
from .const import CONF_MESSAGE, CONF_PRIORITY, CONF_TAGS, CONF_TITLE
23+
from .const import (
24+
CONF_MESSAGE,
25+
CONF_PRIORITY,
26+
CONF_TAGS,
27+
CONF_TITLE,
28+
CONF_TOPIC,
29+
DOMAIN,
30+
)
2331
from .coordinator import NtfyConfigEntry
2432
from .entity import NtfyBaseEntity
2533

@@ -100,6 +108,16 @@ async def ws_connect(self) -> None:
100108
if self._attr_available:
101109
_LOGGER.error("Failed to subscribe to topic. Topic is protected")
102110
self._attr_available = False
111+
ir.async_create_issue(
112+
self.hass,
113+
DOMAIN,
114+
f"topic_protected_{self.topic}",
115+
is_fixable=True,
116+
severity=ir.IssueSeverity.ERROR,
117+
translation_key="topic_protected",
118+
translation_placeholders={CONF_TOPIC: self.topic},
119+
data={"entity_id": self.entity_id, "topic": self.topic},
120+
)
103121
return
104122
except NtfyHTTPError as e:
105123
if self._attr_available:

homeassistant/components/ntfy/quality_scale.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,7 @@ rules:
6363
exception-translations: done
6464
icon-translations: done
6565
reconfiguration-flow: done
66-
repair-issues:
67-
status: exempt
68-
comment: the integration has no repairs
66+
repair-issues: done
6967
stale-devices:
7068
status: exempt
7169
comment: only one device per entry, is deleted with the entry.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Repairs for ntfy integration."""
2+
3+
from __future__ import annotations
4+
5+
import voluptuous as vol
6+
7+
from homeassistant import data_entry_flow
8+
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import entity_registry as er
11+
12+
from .const import CONF_TOPIC
13+
14+
15+
class TopicProtectedRepairFlow(RepairsFlow):
16+
"""Handler for protected topic issue fixing flow."""
17+
18+
def __init__(self, data: dict[str, str]) -> None:
19+
"""Initialize."""
20+
self.entity_id = data["entity_id"]
21+
self.topic = data["topic"]
22+
23+
async def async_step_init(
24+
self, user_input: dict[str, str] | None = None
25+
) -> data_entry_flow.FlowResult:
26+
"""Init repair flow."""
27+
28+
return await self.async_step_confirm()
29+
30+
async def async_step_confirm(
31+
self, user_input: dict[str, str] | None = None
32+
) -> data_entry_flow.FlowResult:
33+
"""Confirm repair flow."""
34+
if user_input is not None:
35+
er.async_get(self.hass).async_update_entity(
36+
self.entity_id,
37+
disabled_by=er.RegistryEntryDisabler.USER,
38+
)
39+
return self.async_create_entry(data={})
40+
41+
return self.async_show_form(
42+
step_id="confirm",
43+
data_schema=vol.Schema({}),
44+
description_placeholders={CONF_TOPIC: self.topic},
45+
)
46+
47+
48+
async def async_create_fix_flow(
49+
hass: HomeAssistant,
50+
issue_id: str,
51+
data: dict[str, str],
52+
) -> RepairsFlow:
53+
"""Create flow."""
54+
if issue_id.startswith("topic_protected"):
55+
return TopicProtectedRepairFlow(data)
56+
return ConfirmRepairFlow()

homeassistant/components/ntfy/strings.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,5 +354,18 @@
354354
"5": "Maximum"
355355
}
356356
}
357+
},
358+
"issues": {
359+
"topic_protected": {
360+
"title": "Subscription failed: Topic {topic} is protected",
361+
"fix_flow": {
362+
"step": {
363+
"confirm": {
364+
"title": "Topic {topic} is protected",
365+
"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."
366+
}
367+
}
368+
}
369+
}
357370
}
358371
}

tests/components/ntfy/test_event.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,20 @@
1717
import pytest
1818
from syrupy.assertion import SnapshotAssertion
1919

20+
from homeassistant.components.ntfy.const import DOMAIN
2021
from homeassistant.config_entries import ConfigEntryState
2122
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
2223
from homeassistant.core import HomeAssistant
23-
from homeassistant.helpers import entity_registry as er
24+
from homeassistant.helpers import entity_registry as er, issue_registry as ir
25+
from homeassistant.setup import async_setup_component
2426

2527
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
28+
from tests.components.repairs import (
29+
async_process_repairs_platforms,
30+
process_repair_fix_flow,
31+
start_repair_fix_flow,
32+
)
33+
from tests.typing import ClientSessionGenerator
2634

2735

2836
@pytest.fixture(autouse=True)
@@ -156,3 +164,48 @@ async def test_event_exceptions(
156164

157165
assert (state := hass.states.get("event.mytopic"))
158166
assert state.state == expected_state
167+
168+
169+
async def test_event_topic_protected(
170+
hass: HomeAssistant,
171+
config_entry: MockConfigEntry,
172+
mock_aiontfy: AsyncMock,
173+
freezer: FrozenDateTimeFactory,
174+
issue_registry: ir.IssueRegistry,
175+
entity_registry: er.EntityRegistry,
176+
hass_client: ClientSessionGenerator,
177+
) -> None:
178+
"""Test ntfy events cannot subscribe to protected topic."""
179+
mock_aiontfy.subscribe.side_effect = NtfyForbiddenError(403, 403, "forbidden")
180+
181+
config_entry.add_to_hass(hass)
182+
assert await async_setup_component(hass, "repairs", {})
183+
await hass.config_entries.async_setup(config_entry.entry_id)
184+
await hass.async_block_till_done()
185+
186+
assert config_entry.state is ConfigEntryState.LOADED
187+
188+
freezer.tick(timedelta(seconds=10))
189+
async_fire_time_changed(hass)
190+
await hass.async_block_till_done()
191+
192+
assert (state := hass.states.get("event.mytopic"))
193+
assert state.state == STATE_UNAVAILABLE
194+
195+
assert issue_registry.async_get_issue(
196+
domain=DOMAIN, issue_id="topic_protected_mytopic"
197+
)
198+
199+
await async_process_repairs_platforms(hass)
200+
client = await hass_client()
201+
result = await start_repair_fix_flow(client, DOMAIN, "topic_protected_mytopic")
202+
203+
flow_id = result["flow_id"]
204+
assert result["step_id"] == "confirm"
205+
206+
result = await process_repair_fix_flow(client, flow_id)
207+
assert result["type"] == "create_entry"
208+
209+
assert (entity := entity_registry.async_get("event.mytopic"))
210+
assert entity.disabled
211+
assert entity.disabled_by is er.RegistryEntryDisabler.USER

0 commit comments

Comments
 (0)