diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 9c1c4088078..0b5d0febbe7 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -2,7 +2,11 @@ "domain": "signal_messenger", "name": "Signal Messenger", "documentation": "https://www.home-assistant.io/integrations/signal_messenger", - "codeowners": ["@bbernhard"], - "requirements": ["pysignalclirestapi==0.3.4"], + "codeowners": [ + "@bbernhard" + ], + "requirements": [ + "pysignalclirestapi==0.3.18" + ], "iot_class": "cloud_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index a1a165e9608..7f48d83ba19 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -1,7 +1,11 @@ """Signal Messenger for notify component.""" +from __future__ import annotations + import logging +from typing import Any from pysignalclirestapi import SignalCliRestApi, SignalCliRestApiError +import requests import voluptuous as vol from homeassistant.components.notify import ( @@ -9,15 +13,34 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) CONF_SENDER_NR = "number" CONF_RECP_NR = "recipients" CONF_SIGNAL_CLI_REST_API = "url" -ATTR_FILENAME = "attachment" +CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES = 52428800 ATTR_FILENAMES = "attachments" +ATTR_URLS = "urls" +ATTR_VERIFY_SSL = "verify_ssl" + +DATA_FILENAMES_SCHEMA = vol.Schema({vol.Required(ATTR_FILENAMES): [cv.string]}) + +DATA_URLS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_URLS): [cv.url], + vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean, + } +) + +DATA_SCHEMA = vol.Any( + None, + DATA_FILENAMES_SCHEMA, + DATA_URLS_SCHEMA, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -28,7 +51,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_service(hass, config, discovery_info=None): +def get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SignalNotificationService: """Get the SignalMessenger notification service.""" sender_nr = config[CONF_SENDER_NR] @@ -37,43 +64,118 @@ def get_service(hass, config, discovery_info=None): signal_cli_rest_api = SignalCliRestApi(signal_cli_rest_api_url, sender_nr) - return SignalNotificationService(recp_nrs, signal_cli_rest_api) + return SignalNotificationService(hass, recp_nrs, signal_cli_rest_api) class SignalNotificationService(BaseNotificationService): """Implement the notification service for SignalMessenger.""" - def __init__(self, recp_nrs, signal_cli_rest_api): + def __init__( + self, + hass: HomeAssistant, + recp_nrs: list[str], + signal_cli_rest_api: SignalCliRestApi, + ) -> None: """Initialize the service.""" + self._hass = hass self._recp_nrs = recp_nrs self._signal_cli_rest_api = signal_cli_rest_api - def send_message(self, message="", **kwargs): - """Send a message to a one or more recipients. - - Additionally a file can be attached. - """ + def send_message(self, message: str = "", **kwargs: Any) -> None: + """Send a message to a one or more recipients. Additionally a file can be attached.""" _LOGGER.debug("Sending signal message") data = kwargs.get(ATTR_DATA) - filenames = None - if data is not None: - if ATTR_FILENAMES in data: - filenames = data[ATTR_FILENAMES] - if ATTR_FILENAME in data: - _LOGGER.warning( - "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108" - ) - if filenames is None: - filenames = [data[ATTR_FILENAME]] - else: - filenames.append(data[ATTR_FILENAME]) + try: + data = DATA_SCHEMA(data) + except vol.Invalid as ex: + _LOGGER.error("Invalid message data: %s", ex) + raise ex + + filenames = self.get_filenames(data) + attachments_as_bytes = self.get_attachments_as_bytes( + data, CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, self._hass + ) try: - self._signal_cli_rest_api.send_message(message, self._recp_nrs, filenames) + self._signal_cli_rest_api.send_message( + message, self._recp_nrs, filenames, attachments_as_bytes + ) except SignalCliRestApiError as ex: _LOGGER.error("%s", ex) raise ex + + @staticmethod + def get_filenames(data: Any) -> list[str] | None: + """Extract attachment filenames from data.""" + try: + data = DATA_FILENAMES_SCHEMA(data) + except vol.Invalid: + return None + + return data[ATTR_FILENAMES] + + @staticmethod + def get_attachments_as_bytes( + data: Any, + attachment_size_limit: int, + hass: HomeAssistant, + ) -> list[bytearray] | None: + """Retrieve attachments from URLs defined in data.""" + try: + data = DATA_URLS_SCHEMA(data) + except vol.Invalid: + return None + + urls = data[ATTR_URLS] + + attachments_as_bytes: list[bytearray] = [] + + for url in urls: + try: + if not hass.config.is_allowed_external_url(url): + _LOGGER.error("URL '%s' not in allow list", url) + continue + + resp = requests.get( + url, verify=data[ATTR_VERIFY_SSL], timeout=10, stream=True + ) + resp.raise_for_status() + + if ( + resp.headers.get("Content-Length") is not None + and int(str(resp.headers.get("Content-Length"))) + > attachment_size_limit + ): + raise ValueError( + "Attachment too large (Content-Length reports {}). Max size: {} bytes".format( + int(str(resp.headers.get("Content-Length"))), + CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, + ) + ) + + size = 0 + chunks = bytearray() + for chunk in resp.iter_content(1024): + size += len(chunk) + if size > attachment_size_limit: + raise ValueError( + "Attachment too large (Stream reports {}). Max size: {} bytes".format( + size, CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES + ) + ) + + chunks.extend(chunk) + + attachments_as_bytes.append(chunks) + except Exception as ex: + _LOGGER.error("%s", ex) + raise ex + + if not attachments_as_bytes: + return None + + return attachments_as_bytes diff --git a/requirements_all.txt b/requirements_all.txt index 354d418b4be..8aa1f6d3a14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ pysher==1.0.1 pysiaalarm==3.0.2 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.4 +pysignalclirestapi==0.3.18 # homeassistant.components.sky_hub pyskyqhub==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b58bbde3ab..ae8bf2fe7b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1155,7 +1155,7 @@ pyserial==3.5 pysiaalarm==3.0.2 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.4 +pysignalclirestapi==0.3.18 # homeassistant.components.sma pysma==0.6.10 diff --git a/tests/components/signal_messenger/conftest.py b/tests/components/signal_messenger/conftest.py index bc7aa7f84a1..017f598b93c 100644 --- a/tests/components/signal_messenger/conftest.py +++ b/tests/components/signal_messenger/conftest.py @@ -3,37 +3,69 @@ from http import HTTPStatus from pysignalclirestapi import SignalCliRestApi import pytest +from requests_mock.mocker import Mocker from homeassistant.components.signal_messenger.notify import SignalNotificationService - - -@pytest.fixture -def signal_notification_service(): - """Set up signal notification service.""" - recipients = ["+435565656565"] - number = "+43443434343" - client = SignalCliRestApi("http://127.0.0.1:8080", number) - return SignalNotificationService(recipients, client) - +from homeassistant.core import HomeAssistant SIGNAL_SEND_PATH_SUFIX = "/v2/send" MESSAGE = "Testing Signal Messenger platform :)" +CONTENT = b"TestContent" NUMBER_FROM = "+43443434343" NUMBERS_TO = ["+435565656565"] +URL_ATTACHMENT = "http://127.0.0.1:8080/image.jpg" @pytest.fixture -def signal_requests_mock(requests_mock): - """Prepare signal service mock.""" - requests_mock.register_uri( - "POST", - "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, - status_code=HTTPStatus.CREATED, - ) - requests_mock.register_uri( - "GET", - "http://127.0.0.1:8080/v1/about", - status_code=HTTPStatus.OK, - json={"versions": ["v1", "v2"]}, - ) - return requests_mock +def signal_notification_service(hass: HomeAssistant) -> SignalNotificationService: + """Set up signal notification service.""" + hass.config.allowlist_external_urls.add(URL_ATTACHMENT) + recipients = ["+435565656565"] + number = "+43443434343" + client = SignalCliRestApi("http://127.0.0.1:8080", number) + return SignalNotificationService(hass, recipients, client) + + +@pytest.fixture +def signal_requests_mock_factory(requests_mock: Mocker) -> Mocker: + """Create signal service mock from factory.""" + + def _signal_requests_mock_factory( + success_send_result: bool = True, content_length_header: str = None + ) -> Mocker: + requests_mock.register_uri( + "GET", + "http://127.0.0.1:8080/v1/about", + status_code=HTTPStatus.OK, + json={"versions": ["v1", "v2"]}, + ) + if success_send_result: + requests_mock.register_uri( + "POST", + "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + status_code=HTTPStatus.CREATED, + ) + else: + requests_mock.register_uri( + "POST", + "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + status_code=HTTPStatus.BAD_REQUEST, + ) + if content_length_header is not None: + requests_mock.register_uri( + "GET", + URL_ATTACHMENT, + status_code=HTTPStatus.OK, + content=CONTENT, + headers={"Content-Length": content_length_header}, + ) + else: + requests_mock.register_uri( + "GET", + URL_ATTACHMENT, + status_code=HTTPStatus.OK, + content=CONTENT, + ) + return requests_mock + + return _signal_requests_mock_factory diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index 1feefa28513..6ed57813f46 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -1,23 +1,33 @@ """The tests for the signal_messenger platform.""" +import base64 import json import logging import os import tempfile from unittest.mock import patch +from pysignalclirestapi.api import SignalCliRestApiError +import pytest +from requests_mock.mocker import Mocker +import voluptuous as vol + +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.components.signal_messenger.conftest import ( + CONTENT, MESSAGE, NUMBER_FROM, NUMBERS_TO, SIGNAL_SEND_PATH_SUFIX, + URL_ATTACHMENT, + SignalNotificationService, ) BASE_COMPONENT = "notify" -async def test_signal_messenger_init(hass): +async def test_signal_messenger_init(hass: HomeAssistant) -> None: """Test that service loads successfully.""" config = { BASE_COMPONENT: { @@ -36,8 +46,13 @@ async def test_signal_messenger_init(hass): assert hass.services.has_service(BASE_COMPONENT, "test") -def test_send_message(signal_notification_service, signal_requests_mock, caplog): +def test_send_message( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: """Test send message.""" + signal_requests_mock = signal_requests_mock_factory() with caplog.at_level( logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" ): @@ -48,32 +63,58 @@ def test_send_message(signal_notification_service, signal_requests_mock, caplog) assert_sending_requests(signal_requests_mock) -def test_send_message_should_show_deprecation_warning( - signal_notification_service, signal_requests_mock, caplog -): - """Test send message should show deprecation warning.""" - with caplog.at_level( - logging.WARNING, logger="homeassistant.components.signal_messenger.notify" - ): - send_message_with_attachment(signal_notification_service, True) - - assert ( - "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108" - in caplog.text - ) - assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 - assert_sending_requests(signal_requests_mock, 1) - - -def test_send_message_with_attachment( - signal_notification_service, signal_requests_mock, caplog -): - """Test send message with attachment.""" +def test_send_message_to_api_with_bad_data_throws_error( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending a message with bad data to the API throws an error.""" + signal_requests_mock = signal_requests_mock_factory(False) with caplog.at_level( logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" ): - send_message_with_attachment(signal_notification_service, False) + with pytest.raises(SignalCliRestApiError) as exc: + signal_notification_service.send_message(MESSAGE) + + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert "Couldn't send signal message" in str(exc.value) + + +def test_send_message_with_bad_data_throws_vol_error( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending a message with bad data throws an error.""" + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + with pytest.raises(vol.Invalid) as exc: + data = {"test": "test"} + signal_notification_service.send_message(MESSAGE, **{"data": data}) + + assert "Sending signal message" in caplog.text + assert "extra keys not allowed" in str(exc.value) + + +def test_send_message_with_attachment( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with attachment.""" + signal_requests_mock = signal_requests_mock_factory() + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".png", prefix=os.path.basename(__file__) + ) as temp_file: + temp_file.write("attachment_data") + data = {"attachments": [temp_file.name]} + signal_notification_service.send_message(MESSAGE, **{"data": data}) assert "Sending signal message" in caplog.text assert signal_requests_mock.called @@ -81,19 +122,211 @@ def test_send_message_with_attachment( assert_sending_requests(signal_requests_mock, 1) -def send_message_with_attachment(signal_notification_service, deprecated=False): - """Send message with attachment.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".png", prefix=os.path.basename(__file__) - ) as tf: - tf.write("attachment_data") - data = {"attachment": tf.name} if deprecated else {"attachments": [tf.name]} +def test_send_message_with_attachment_as_url( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with attachment as URL.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT))) + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + data = {"urls": [URL_ATTACHMENT]} signal_notification_service.send_message(MESSAGE, **{"data": data}) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 3 + assert_sending_requests(signal_requests_mock, 1) -def assert_sending_requests(signal_requests_mock, attachments_num=0): + +def test_get_attachments( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT))) + data = {"urls": [URL_ATTACHMENT]} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert result == [bytearray(CONTENT)] + + +def test_get_attachments_not_on_allowlist( + signal_notification_service: SignalNotificationService, + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL that aren't on the allowlist.""" + url = "http://dodgyurl.com" + data = {"urls": [url]} + with caplog.at_level( + logging.ERROR, logger="homeassistant.components.signal_messenger.notify" + ): + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert f"URL '{url}' not in allow list" in caplog.text + assert result is None + + +def test_get_attachments_with_large_attachment( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with large attachment (per Content-Length header) throws error.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT) + 1)) + with pytest.raises(ValueError) as exc: + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert "Attachment too large (Content-Length reports" in str(exc.value) + + +def test_get_attachments_with_large_attachment_no_header( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with large attachment (per content length) throws error.""" + signal_requests_mock = signal_requests_mock_factory() + with pytest.raises(ValueError) as exc: + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT) - 1, hass + ) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert "Attachment too large (Stream reports" in str(exc.value) + + +def test_get_filenames_with_none_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with None data returns None.""" + data = None + result = signal_notification_service.get_filenames(data) + + assert result is None + + +def test_get_filenames_with_attachments_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with 'attachments' in data.""" + data = {"attachments": ["test"]} + result = signal_notification_service.get_filenames(data) + + assert result == ["test"] + + +def test_get_filenames_with_multiple_attachments_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with multiple 'attachments' in data.""" + data = {"attachments": ["test", "test2"]} + result = signal_notification_service.get_filenames(data) + + assert result == ["test", "test2"] + + +def test_get_filenames_with_non_list_returns_none( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with non list data.""" + data = {"attachments": "test"} + result = signal_notification_service.get_filenames(data) + + assert result is None + + +def test_get_attachments_with_non_list_returns_none( + signal_notification_service: SignalNotificationService, + hass: HomeAssistant, +) -> None: + """Test getting attachments with non list data.""" + data = {"urls": URL_ATTACHMENT} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert result is None + + +def test_get_attachments_with_verify_unset( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl unset results in verify=true.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is True + + +def test_get_attachments_with_verify_set_true( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to true results in verify=true.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"verify_ssl": True, "urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is True + + +def test_get_attachments_with_verify_set_false( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to false results in verify=false.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"verify_ssl": False, "urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is False + + +def test_get_attachments_with_verify_set_garbage( + signal_notification_service: SignalNotificationService, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to garbage results in None.""" + data = {"verify_ssl": "test", "urls": [URL_ATTACHMENT]} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert result is None + + +def assert_sending_requests( + signal_requests_mock_factory: Mocker, attachments_num: int = 0 +) -> None: """Assert message was send with correct parameters.""" - send_request = signal_requests_mock.request_history[-1] + send_request = signal_requests_mock_factory.request_history[-1] assert send_request.path == SIGNAL_SEND_PATH_SUFIX body_request = json.loads(send_request.text) @@ -101,3 +334,7 @@ def assert_sending_requests(signal_requests_mock, attachments_num=0): assert body_request["number"] == NUMBER_FROM assert body_request["recipients"] == NUMBERS_TO assert len(body_request["base64_attachments"]) == attachments_num + + for attachment in body_request["base64_attachments"]: + if len(attachment) > 0: + assert base64.b64decode(attachment) == CONTENT