"""Test HTML5 notify platform.""" from collections.abc import Generator from http import HTTPStatus import json from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from aiohttp import ClientError from aiohttp.hdrs import AUTHORIZATION import pytest from pywebpush import WebPushException from syrupy.assertion import SnapshotAssertion from homeassistant.components.html5 import DOMAIN, notify as html5 from homeassistant.components.html5.const import ( ATTR_ACTIONS, ATTR_BADGE, ATTR_DIR, ATTR_ICON, ATTR_IMAGE, ATTR_LANG, ATTR_RENOTIFY, ATTR_REQUIRE_INTERACTION, ATTR_SILENT, ATTR_TAG, ATTR_TIMESTAMP, ATTR_TTL, ATTR_URGENCY, ATTR_VIBRATE, SERVICE_DISMISS, ) from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_DISMISS, DEFAULT_TTL from homeassistant.components.html5.services import SERVICE_DISMISS_MESSAGE from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, snapshot_platform from tests.typing import ClientSessionGenerator CONFIG_FILE = "file.conf" VAPID_CONF = { "platform": "html5", "vapid_pub_key": ( "BJMA2gDZEkHaXRhf1fhY_" "QbKbhVIHlSJXI0bFyo0eJXnUPOjdgycCAbj-2bMKMKNKs" "_rM8JoSnyKGCXAY2dbONI" ), "vapid_prv_key": "ZwPgwKpESGuGLMZYU39vKgrekrWzCijo-LsBM3CZ9-c", "vapid_email": "someone@example.com", } SUBSCRIPTION_1 = { "browser": "chrome", "subscription": { "endpoint": "https://googleapis.com", "keys": {"auth": "auth", "p256dh": "p256dh"}, }, } SUBSCRIPTION_2 = { "browser": "firefox", "subscription": { "endpoint": "https://example.com", "keys": {"auth": "bla", "p256dh": "bla"}, }, } SUBSCRIPTION_3 = { "browser": "chrome", "subscription": { "endpoint": "https://example.com/not_exist", "keys": {"auth": "bla", "p256dh": "bla"}, }, } SUBSCRIPTION_4 = { "browser": "chrome", "subscription": { "endpoint": "https://googleapis.com", "expirationTime": None, "keys": {"auth": "auth", "p256dh": "p256dh"}, }, } SUBSCRIPTION_5 = { "browser": "chrome", "subscription": { "endpoint": "https://fcm.googleapis.com/fcm/send/LONG-RANDOM-KEY", "expirationTime": None, "keys": {"auth": "auth", "p256dh": "p256dh"}, }, } REGISTER_URL = "/api/notify.html5" PUBLISH_URL = "/api/notify.html5/callback" VAPID_HEADERS = { "Authorization": "vapid t=signed!!!", "urgency": "normal", "priority": "normal", } @pytest.fixture(autouse=True) def notify_only() -> Generator[None]: """Enable only the notify platform.""" with patch( "homeassistant.components.html5.PLATFORMS", [Platform.NOTIFY], ): yield async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" await async_setup_component(hass, "http", {}) m = mock_open() with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) assert service is not 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", {}) data = {"device": SUBSCRIPTION_1} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_dismiss(target=["device", "non_existing"], data={"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, ) @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", {}) data = {"device": SUBSCRIPTION_1} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_send_message( "Hello", target=["device", "non_existing"], data={"icon": "beer.png"} ) 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.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] @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", {}) data = {"chrome": SUBSCRIPTION_5} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_send_message("Hello", target=["chrome"]) 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.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_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", {}) data = {"chrome": SUBSCRIPTION_5} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_send_message("Hello", target=["chrome"], priority="undefined") 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.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_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", {}) data = {"chrome": SUBSCRIPTION_5} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_send_message("Hello") 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.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_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", {}) data = {"chrome": SUBSCRIPTION_5} m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None await service.async_send_message("Hello", data={"mykey": "myvalue"}) 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.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] @pytest.mark.usefixtures("load_config") async def test_registering_new_device_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) assert resp.status == HTTPStatus.OK assert len(mock_save.mock_calls) == 1 assert mock_save.mock_calls[0][1][1] == {"unnamed device": SUBSCRIPTION_1} @pytest.mark.usefixtures("load_config") async def test_registering_new_device_view_with_name( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works with name attribute.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() SUB_WITH_NAME = SUBSCRIPTION_1.copy() SUB_WITH_NAME["name"] = "test device" with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME)) assert resp.status == HTTPStatus.OK assert len(mock_save.mock_calls) == 1 assert mock_save.mock_calls[0][1][1] == {"test device": SUBSCRIPTION_1} @pytest.mark.usefixtures("load_config") async def test_registering_new_device_expiration_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.OK assert mock_save.mock_calls[0][1][1] == {"unnamed device": SUBSCRIPTION_4} @pytest.mark.usefixtures("load_config") async def test_registering_new_device_fails_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test subs. are not altered when registering a new device fails.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), ): resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR @pytest.mark.usefixtures("load_config") async def test_registering_existing_device_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test subscription is updated when registering existing device.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.OK mock_save.assert_called_with( hass.config.path(html5.REGISTRATIONS_FILE), {"unnamed device": SUBSCRIPTION_4} ) @pytest.mark.usefixtures("load_config") async def test_registering_existing_device_view_with_name( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test subscription is updated when reg'ing existing device with name.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() SUB_WITH_NAME = SUBSCRIPTION_1.copy() SUB_WITH_NAME["name"] = "test device" with patch("homeassistant.components.html5.notify.save_json") as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME)) resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.OK mock_save.assert_called_with( hass.config.path(html5.REGISTRATIONS_FILE), {"test device": SUBSCRIPTION_4} ) @pytest.mark.usefixtures("load_config") async def test_registering_existing_device_fails_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test sub. is not updated when registering existing device fails.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) mock_save.side_effect = HomeAssistantError resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR @pytest.mark.usefixtures("load_config") async def test_registering_new_device_validation( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test various errors when registering a new device.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() resp = await client.post( REGISTER_URL, data=json.dumps({"browser": "invalid browser", "subscription": "sub info"}), ) assert resp.status == HTTPStatus.BAD_REQUEST resp = await client.post(REGISTER_URL, data=json.dumps({"browser": "chrome"})) assert resp.status == HTTPStatus.BAD_REQUEST with patch("homeassistant.components.html5.notify.save_json", return_value=False): resp = await client.post( REGISTER_URL, data=json.dumps({"browser": "chrome", "subscription": "sub info"}), ) assert resp.status == HTTPStatus.BAD_REQUEST async def test_unregistering_device_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, load_config: MagicMock, ) -> None: """Test that the HTML unregister view works.""" load_config.return_value = { "some device": SUBSCRIPTION_1, "other device": SUBSCRIPTION_2, } await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.delete( REGISTER_URL, data=json.dumps({"subscription": SUBSCRIPTION_1["subscription"]}), ) assert resp.status == HTTPStatus.OK assert len(mock_save.mock_calls) == 1 mock_save.assert_called_once_with( hass.config.path(html5.REGISTRATIONS_FILE), {"other device": SUBSCRIPTION_2} ) @pytest.mark.usefixtures("load_config") async def test_unregister_device_view_handle_unknown_subscription( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test that the HTML unregister view handles unknown subscriptions.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.delete( REGISTER_URL, data=json.dumps({"subscription": SUBSCRIPTION_3["subscription"]}), ) assert resp.status == HTTPStatus.OK, resp.response assert len(mock_save.mock_calls) == 0 async def test_unregistering_device_view_handles_save_error( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, load_config: MagicMock, ) -> None: """Test that the HTML unregister view handles save errors.""" load_config.return_value = { "some device": SUBSCRIPTION_1, "other device": SUBSCRIPTION_2, } await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() with patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), ): resp = await client.delete( REGISTER_URL, data=json.dumps({"subscription": SUBSCRIPTION_1["subscription"]}), ) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR, resp.response @pytest.mark.usefixtures("load_config") async def test_callback_view_no_jwt( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, ) -> None: """Test that the notification callback view works without JWT.""" await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() resp = await client.post( PUBLISH_URL, data=json.dumps( {"type": "push", "tag": "3bc28d69-0921-41f1-ac6a-7a627ba0aa72"} ), ) 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} await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED client = await hass_client() 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.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] bearer_token = "Bearer JWT" resp = await client.post( PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token} ) assert resp.status == HTTPStatus.OK body = await resp.json() 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} await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED 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.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} await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) 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.save_json") as mock_save, ): await hass.services.async_call( "notify", "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) # "device" should be removed when 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} await async_setup_component(hass, "http", {}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) 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.save_json", side_effect=HomeAssistantError(), ), ): await hass.services.async_call( "notify", "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) # "device" should still exist if save fails. assert "Error saving registration" in caplog.text async def test_notify_platform( hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, load_config: MagicMock, ) -> None: """Test setup of the notify platform.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, ) -> None: """Test sending a message.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED state = hass.states.get("notify.my_desktop") assert state assert state.state == STATE_UNKNOWN await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", ATTR_MESSAGE: "World", ATTR_TITLE: "Hello", }, blocking=True, ) state = hass.states.get("notify.my_desktop") assert state assert state.state == "2009-02-13T23:31:30+00:00" webpush_async.assert_awaited_once() assert webpush_async.await_args _, payload, _, _ = webpush_async.await_args.args assert json.loads(payload) == { "title": "Hello", "body": "World", "badge": "/static/images/notification-badge.png", "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "timestamp": 1234567890000, "data": {"jwt": "JWT"}, } @pytest.mark.parametrize( ("exception", "translation_key"), [ ( WebPushException("", response=Mock(status=HTTPStatus.IM_A_TEAPOT)), "request_error", ), ( WebPushException("", response=Mock(status=HTTPStatus.GONE)), "channel_expired", ), ( ClientError, "connection_error", ), ], ) @pytest.mark.parametrize("domain", [NOTIFY_DOMAIN, DOMAIN]) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, exception: Exception, translation_key: str, domain: str, ) -> None: """Test sending a message with exceptions.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED webpush_async.side_effect = exception with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( domain, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", ATTR_MESSAGE: "World", ATTR_TITLE: "Hello", }, blocking=True, ) assert e.value.translation_key == translation_key @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message_save_fails( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test sending a message with channel expired but saving registration fails.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED webpush_async.side_effect = ( WebPushException("", response=Mock(status=HTTPStatus.GONE)), ) with ( patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError, ), pytest.raises(HomeAssistantError) as e, ): await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", ATTR_MESSAGE: "World", ATTR_TITLE: "Hello", }, blocking=True, ) assert e.value.translation_key == "channel_expired" assert "Error saving registration" in caplog.text @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, ) -> None: """Test sending a message with channel expired and entity goes unavailable.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED webpush_async.side_effect = ( WebPushException("", response=Mock(status=HTTPStatus.GONE)), ) with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", ATTR_MESSAGE: "World", ATTR_TITLE: "Hello", }, blocking=True, ) assert e.value.translation_key == "channel_expired" state = hass.states.get("notify.my_desktop") assert state assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( ("service_data", "expected_payload", "expected_ttl", "expected_headers"), [ ({ATTR_MESSAGE: "World"}, {"body": "World"}, DEFAULT_TTL, None), ( {ATTR_ICON: "/static/icons/favicon-192x192.png"}, {"icon": "/static/icons/favicon-192x192.png"}, DEFAULT_TTL, None, ), ( {ATTR_BADGE: "/static/images/notification-badge.png"}, {"badge": "/static/images/notification-badge.png"}, DEFAULT_TTL, None, ), ( {ATTR_IMAGE: "/static/images/image.jpg"}, {"image": "/static/images/image.jpg"}, DEFAULT_TTL, None, ), ({ATTR_TAG: "message-group-1"}, {"tag": "message-group-1"}, DEFAULT_TTL, None), ({ATTR_DIR: "rtl"}, {"dir": "rtl"}, DEFAULT_TTL, None), ({ATTR_RENOTIFY: True}, {"renotify": True}, DEFAULT_TTL, None), ({ATTR_SILENT: True}, {"silent": True}, DEFAULT_TTL, None), ( {ATTR_REQUIRE_INTERACTION: True}, {"requireInteraction": True}, DEFAULT_TTL, None, ), ( {ATTR_VIBRATE: [200, 100, 200]}, {"vibrate": [200, 100, 200]}, DEFAULT_TTL, None, ), ({ATTR_LANG: "es-419"}, {"lang": "es-419"}, DEFAULT_TTL, None), ({ATTR_TIMESTAMP: "1970-01-01 00:00:00"}, {"timestamp": 0}, DEFAULT_TTL, None), ({ATTR_TTL: {"days": 28}}, {}, 2419200, None), ({ATTR_TTL: {"seconds": 0}}, {}, 0, None), ( {ATTR_URGENCY: "high"}, {}, DEFAULT_TTL, {"Urgency": "high"}, ), ( { ATTR_ACTIONS: [ { ATTR_ACTION: "callback-event", ATTR_TITLE: "Callback Event", ATTR_ICON: "/static/icons/favicon-192x192.png", } ] }, { "actions": [ { "action": "callback-event", "title": "Callback Event", "icon": "/static/icons/favicon-192x192.png", } ] }, DEFAULT_TTL, None, ), ( {ATTR_DATA: {"customKey": "customValue"}}, {"data": {"jwt": "JWT", "customKey": "customValue"}}, DEFAULT_TTL, None, ), ], ) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_html5_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, service_data: dict[str, Any], expected_payload: dict[str, Any], expected_ttl: int, expected_headers: dict[str, Any] | None, ) -> None: """Test sending a message via html5.send_message action.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED state = hass.states.get("notify.my_desktop") assert state assert state.state == STATE_UNKNOWN await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, {ATTR_ENTITY_ID: "notify.my_desktop", ATTR_TITLE: "Hello", **service_data}, blocking=True, ) state = hass.states.get("notify.my_desktop") assert state assert state.state == "2009-02-13T23:31:30+00:00" webpush_async.assert_awaited_once() assert webpush_async.await_args _, payload, _, _ = webpush_async.await_args.args assert json.loads(payload) == { "title": "Hello", "tag": "12345678-1234-5678-1234-567812345678", "timestamp": 1234567890000, "data": {"jwt": "JWT"}, **expected_payload, } assert webpush_async.await_args.kwargs["ttl"] == expected_ttl assert webpush_async.await_args.kwargs["headers"] == expected_headers @pytest.mark.parametrize( ("domain", "service", "service_data", "issue_id"), [ ( NOTIFY_DOMAIN, "html5_my_desktop", {ATTR_MESSAGE: "Hello", ATTR_TARGET: ["my-desktop"]}, "deprecated_notify_action_notify.html5_my_desktop", ), ( NOTIFY_DOMAIN, DOMAIN, {ATTR_MESSAGE: "Hello"}, "deprecated_notify_action_notify.html5", ), ( NOTIFY_DOMAIN, DOMAIN, {ATTR_MESSAGE: "Hello", ATTR_TARGET: ["my-desktop", "my-phone"]}, "deprecated_notify_action_notify.html5", ), ( DOMAIN, SERVICE_DISMISS, {}, "deprecated_dismiss_action", ), ], ) @pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") async def test_deprecation_action_call( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, issue_registry: ir.IssueRegistry, domain: str, service: str, service_data: dict[str, Any] | None, issue_id: str, ) -> None: """Test deprecation action call.""" load_config.return_value = { "my-desktop": SUBSCRIPTION_1, "my-phone": SUBSCRIPTION_2, } config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( domain, service, service_data, blocking=True, ) assert issue_registry.async_get_issue( domain=DOMAIN, issue_id=issue_id, ) @pytest.mark.parametrize( ("service_data", "expected_payload"), [ ( {ATTR_TAG: "message-group-1"}, {ATTR_DISMISS: True, ATTR_TAG: "message-group-1"}, ), ( {}, {ATTR_DISMISS: True, ATTR_TAG: ""}, ), ], ) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_html5_dismiss_message( hass: HomeAssistant, config_entry: MockConfigEntry, webpush_async: AsyncMock, load_config: MagicMock, service_data: dict[str, Any], expected_payload: dict[str, Any], ) -> None: """Test dismissing a message via html5.dismiss_message action.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( DOMAIN, SERVICE_DISMISS_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", **service_data, }, blocking=True, ) webpush_async.assert_awaited_once() assert webpush_async.await_args _, payload, _, _ = webpush_async.await_args.args assert json.loads(payload) == { "timestamp": 1234567890000, "data": {"jwt": "JWT"}, **expected_payload, }