diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index ff3354e5c77..49f7522c03c 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -4,7 +4,6 @@ from __future__ import annotations from contextlib import suppress from datetime import datetime, timedelta -from functools import partial from http import HTTPStatus import json import logging @@ -13,7 +12,7 @@ from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from urllib.parse import urlparse import uuid -from aiohttp import web +from aiohttp import ClientSession, web from aiohttp.hdrs import AUTHORIZATION import jwt from py_vapid import Vapid @@ -35,6 +34,7 @@ from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string @@ -203,8 +203,9 @@ async def async_get_service( hass.http.register_view(HTML5PushRegistrationView(registrations, json_path)) hass.http.register_view(HTML5PushCallbackView(registrations)) + session = async_get_clientsession(hass) return HTML5NotificationService( - hass, vapid_prv_key, vapid_email, registrations, json_path + hass, session, vapid_prv_key, vapid_email, registrations, json_path ) @@ -420,12 +421,14 @@ class HTML5NotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, + session: ClientSession, vapid_prv: str, vapid_email: str, registrations: dict[str, Registration], json_path: str, ) -> None: """Initialize the service.""" + self.session = session self._vapid_prv = vapid_prv self._vapid_email = vapid_email self.registrations = registrations @@ -456,22 +459,18 @@ class HTML5NotificationService(BaseNotificationService): """Return a dictionary of registered targets.""" return {registration: registration for registration in self.registrations} - def dismiss(self, **kwargs: Any) -> None: - """Dismisses a notification.""" - data: dict[str, Any] | None = kwargs.get(ATTR_DATA) - tag: str = data.get(ATTR_TAG, "") if data else "" - payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} - - self._push_message(payload, **kwargs) - - async def async_dismiss(self, **kwargs) -> None: + async def async_dismiss(self, **kwargs: Any) -> None: """Dismisses a notification. This method must be run in the event loop. """ - await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs)) + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + tag: str = data.get(ATTR_TAG, "") if data else "" + payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} - def send_message(self, message: str = "", **kwargs: Any) -> None: + await self._push_message(payload, **kwargs) + + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" tag = str(uuid.uuid4()) payload: dict[str, Any] = { @@ -503,9 +502,9 @@ class HTML5NotificationService(BaseNotificationService): ): payload[ATTR_DATA][ATTR_URL] = URL_ROOT - self._push_message(payload, **kwargs) + await self._push_message(payload, **kwargs) - def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: + async def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: """Send the message.""" timestamp = int(time.time()) @@ -535,7 +534,9 @@ class HTML5NotificationService(BaseNotificationService): subscription["keys"]["auth"], ) - webpusher = WebPusher(cast(dict[str, Any], info["subscription"])) + webpusher = WebPusher( + cast(dict[str, Any], info["subscription"]), aiohttp_session=self.session + ) endpoint = urlparse(subscription["endpoint"]) vapid_claims = { @@ -545,28 +546,31 @@ class HTML5NotificationService(BaseNotificationService): } vapid_headers = Vapid.from_string(self._vapid_prv).sign(vapid_claims) vapid_headers.update({"urgency": priority, "priority": priority}) - response = webpusher.send( + + response = await webpusher.send_async( data=json.dumps(payload), headers=vapid_headers, ttl=ttl ) if TYPE_CHECKING: assert not isinstance(response, str) - if response.status_code == HTTPStatus.GONE: + if response.status == HTTPStatus.GONE: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) try: - save_json(self.registrations_json_path, self.registrations) + await self.hass.async_add_executor_job( + save_json, self.registrations_json_path, self.registrations + ) except HomeAssistantError: self.registrations[target] = reg _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") - elif response.status_code >= HTTPStatus.BAD_REQUEST: + elif response.status >= HTTPStatus.BAD_REQUEST: _LOGGER.error( "There was an issue sending the notification %s: %s", - response.status_code, - response.text, + response.status, + await response.text(), ) diff --git a/tests/components/html5/conftest.py b/tests/components/html5/conftest.py index 9c5322b94a6..d24e3102142 100644 --- a/tests/components/html5/conftest.py +++ b/tests/components/html5/conftest.py @@ -1,8 +1,9 @@ """Common fixtures for html5 integration.""" from collections.abc import Generator -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +from aiohttp import ClientResponse import pytest from homeassistant.components.html5.const import ( @@ -45,3 +46,58 @@ def mock_load_config() -> Generator[MagicMock]: "homeassistant.components.html5.notify._load_config", return_value={} ) as mock_load_config: yield mock_load_config + + +@pytest.fixture +def mock_wp() -> Generator[AsyncMock]: + """Mock WebPusher.""" + + with ( + patch( + "homeassistant.components.html5.notify.WebPusher", autospec=True + ) as mock_client, + ): + client = mock_client.return_value + client.cls = mock_client + client.send_async.return_value = AsyncMock(spec=ClientResponse, status=201) + yield client + + +@pytest.fixture +def mock_jwt() -> Generator[MagicMock]: + """Mock JWT.""" + + with ( + patch("homeassistant.components.html5.notify.jwt") as mock_client, + ): + mock_client.encode.return_value = "JWT" + mock_client.decode.return_value = {"target": "device"} + yield mock_client + + +@pytest.fixture +def mock_uuid() -> Generator[MagicMock]: + """Mock UUID.""" + + with ( + patch("homeassistant.components.html5.notify.uuid") as mock_client, + ): + mock_client.uuid4.return_value = "12345678-1234-5678-1234-567812345678" + yield mock_client + + +@pytest.fixture +def mock_vapid() -> Generator[MagicMock]: + """Mock VAPID headers.""" + + with ( + patch( + "homeassistant.components.html5.notify.Vapid", autospec=True + ) as mock_client, + ): + mock_client.from_string.return_value.sign.return_value = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", + } + yield mock_client diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index d1d37cc0e16..3861cca25cd 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -2,7 +2,7 @@ from http import HTTPStatus import json -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch from aiohttp.hdrs import AUTHORIZATION import pytest @@ -71,6 +71,12 @@ SUBSCRIPTION_5 = { REGISTER_URL = "/api/notify.html5" PUBLISH_URL = "/api/notify.html5/callback" +VAPID_HEADERS = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", +} + async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" @@ -82,11 +88,11 @@ async def test_get_service_with_no_json(hass: HomeAssistant) -> None: assert service is not None -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_dismissing_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test dismissing message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -99,23 +105,18 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: await service.async_dismiss(target=["device", "non_existing"], data={"tag": "test"}) - assert len(mock_wp.mock_calls) == 4 - - # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert payload["dismiss"] is True - assert payload["tag"] == "test" + mock_wp.send_async.assert_awaited_once_with( + data='{"tag": "test", "dismiss": true, "data": {"jwt": "JWT"}, "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_sending_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test sending message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -130,23 +131,21 @@ async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: "Hello", target=["device", "non_existing"], data={"icon": "beer.png"} ) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert payload["body"] == "Hello" - assert payload["icon"] == "beer.png" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_key_include(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the FCM header is included.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -159,19 +158,23 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", target=["chrome"]) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) + # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["Authorization"] is not None + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_send_with_unknown_priority( + mock_wp: AsyncMock, hass: HomeAssistant +) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -184,19 +187,20 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> N await service.async_send_message("Hello", target=["chrome"], priority="undefined") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_no_targets(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -209,19 +213,20 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_additional_data(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -234,12 +239,13 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", data={"mykey": "myvalue"}) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"mykey": "myvalue", "url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] @pytest.mark.usefixtures("load_config") @@ -581,11 +587,14 @@ async def test_callback_view_no_jwt( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_callback_view_with_jwt( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification callback view works with JWT.""" load_config.return_value = {"device": SUBSCRIPTION_1} @@ -599,27 +608,22 @@ async def test_callback_view_with_jwt( client = await hass_client() - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] - # Call to send - push_payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert push_payload["body"] == "Hello" - assert push_payload["icon"] == "beer.png" - - bearer_token = f"Bearer {push_payload['data']['jwt']}" + bearer_token = "Bearer JWT" resp = await client.post( PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token} @@ -630,10 +634,13 @@ async def test_callback_view_with_jwt( assert body == {"event": "push", "status": "ok"} +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_without_targets( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification is send with FCM without targets.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -645,25 +652,29 @@ async def test_send_fcm_without_targets( assert config_entry.state is ConfigEntryState.LOADED - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target is removed when expired.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -674,12 +685,10 @@ async def test_send_fcm_expired( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch("homeassistant.components.html5.notify.save_json") as mock_save, ): - mock_wp().send().status_code = 410 await hass.services.async_call( "notify", "html5", @@ -690,11 +699,14 @@ async def test_send_fcm_expired( mock_save.assert_called_once_with(hass.config.path(html5.REGISTRATIONS_FILE), {}) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired_save_fails( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, caplog: pytest.LogCaptureFixture, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target remains after expiry if save_json fails.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -705,16 +717,13 @@ async def test_send_fcm_expired_save_fails( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), ), ): - mock_wp().send().status_code = 410 - await hass.services.async_call( "notify", "html5",