diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index b902d48a1c8..72042de25ed 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from typing import Any import urllib.error @@ -107,7 +108,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): return self.abort_on_import_error(user_input[CONF_URL], "url_error") return self.show_user_form(user_input, {"base": "url_error"}) - feed_title = feed["feed"]["title"] + feed_title = html.unescape(feed["feed"]["title"]) return self.async_create_entry( title=feed_title, diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 6608c4312fe..f45b303946a 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from calendar import timegm from datetime import datetime +import html from logging import getLogger from time import gmtime, struct_time from typing import TYPE_CHECKING @@ -102,7 +103,8 @@ class FeedReaderCoordinator( """Set up the feed manager.""" feed = await self._async_fetch_feed() self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"]) - self.feed_author = feed["feed"].get("author") + if feed_author := feed["feed"].get("author"): + self.feed_author = html.unescape(feed_author) self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"]) self._feed = feed diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index 4b3fb2e2524..ad6aed0fc76 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from feedparser import FeedParserDict @@ -76,15 +77,22 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): # so we always take the first entry in list, since we only care about the latest entry feed_data: FeedParserDict = data[0] + if description := feed_data.get("description"): + description = html.unescape(description) + + if title := feed_data.get("title"): + title = html.unescape(title) + if content := feed_data.get("content"): if isinstance(content, list) and isinstance(content[0], dict): content = content[0].get("value") + content = html.unescape(content) self._trigger_event( EVENT_FEEDREADER, { - ATTR_DESCRIPTION: feed_data.get("description"), - ATTR_TITLE: feed_data.get("title"), + ATTR_DESCRIPTION: description, + ATTR_TITLE: title, ATTR_LINK: feed_data.get("link"), ATTR_CONTENT: content, }, diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 8eeb89e00cd..1e7d50c3835 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -64,6 +64,18 @@ def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader8.xml") +@pytest.fixture(name="feed_htmlentities") +def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes: + """Load test feed data with HTML Entities.""" + return load_fixture_bytes("feedreader9.xml") + + +@pytest.fixture(name="feed_atom_htmlentities") +def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: + """Load test ATOM feed data with HTML Entities.""" + return load_fixture_bytes("feedreader10.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" diff --git a/tests/components/feedreader/fixtures/feedreader10.xml b/tests/components/feedreader/fixtures/feedreader10.xml new file mode 100644 index 00000000000..17ec8069ae1 --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader10.xml @@ -0,0 +1,19 @@ + + + <![CDATA[ATOM RSS en español]]> + + 2024-11-18T14:00:00Z + + + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + + <![CDATA[Título]]> + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2024-11-18T14:00:00Z + + + + diff --git a/tests/components/feedreader/fixtures/feedreader9.xml b/tests/components/feedreader/fixtures/feedreader9.xml new file mode 100644 index 00000000000..580a42cbd3f --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader9.xml @@ -0,0 +1,21 @@ + + + + <![CDATA[RSS en español]]> + + http://www.example.com/main.html + Mon, 18 Nov 2024 15:00:00 +1000 + Mon, 18 Nov 2024 15:00:00 +1000 + 1800 + + + <![CDATA[Título 1]]> + + http://www.example.com/link/1 + GUID 1 + Mon, 18 Nov 2024 15:00:00 +1000 + + + + + diff --git a/tests/components/feedreader/snapshots/test_event.ambr b/tests/components/feedreader/snapshots/test_event.ambr new file mode 100644 index 00000000000..9cce035ea87 --- /dev/null +++ b/tests/components/feedreader/snapshots/test_event.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_event_htmlentities[feed_atom_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido en español', + 'description': 'Resumen en español', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://example.org/2003/12/13/atom03', + 'title': 'Título', + }) +# --- +# name: test_event_htmlentities[feed_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido 1 en español', + 'description': 'Descripción 1', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://www.example.com/link/1', + 'title': 'Título 1', + }) +# --- diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index 2a434306c0f..e801227293c 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -246,3 +246,38 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { CONF_MAX_ENTRIES: 10, } + + +@pytest.mark.parametrize( + ("fixture_name", "expected_title"), + [ + ("feed_htmlentities", "RSS en español"), + ("feed_atom_htmlentities", "ATOM RSS en español"), + ], +) +async def test_feed_htmlentities( + hass: HomeAssistant, + feedparser, + setup_entry, + fixture_name, + expected_title, + request: pytest.FixtureRequest, +) -> None: + """Test starting a flow by user from a feed with HTML Entities in the title.""" + with patch( + "homeassistant.components.feedreader.config_flow.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 491c7e38d02..32f8ecb8080 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -3,6 +3,9 @@ from datetime import timedelta from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.feedreader.event import ( ATTR_CONTENT, ATTR_DESCRIPTION, @@ -59,3 +62,31 @@ async def test_event_entity( assert state.attributes[ATTR_LINK] == "http://www.example.com/link/1" assert state.attributes[ATTR_CONTENT] == "This is a summary" assert state.attributes[ATTR_DESCRIPTION] == "Description 1" + + +@pytest.mark.parametrize( + ("fixture_name"), + [ + ("feed_htmlentities"), + ("feed_atom_htmlentities"), + ], +) +async def test_event_htmlentities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + fixture_name, + request: pytest.FixtureRequest, +) -> None: + """Test feed event entity with HTML Entities.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("event.mock_title") + assert state + assert state.attributes == snapshot diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index d7700d79e3b..bc7a66dc86e 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -12,6 +12,7 @@ import pytest from homeassistant.components.feedreader.const import DOMAIN from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util from . import async_setup_config_entry, create_mock_entry @@ -357,3 +358,23 @@ async def test_feed_errors( freezer.tick(timedelta(hours=1, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_feed_atom_htmlentities( + hass: HomeAssistant, feed_atom_htmlentities, device_registry: dr.DeviceRegistry +) -> None: + """Test ATOM feed author with HTML Entities.""" + + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[feed_atom_htmlentities], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + assert device_entry.manufacturer == "Juan Pérez"