diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index a88082b2ce6d89..dcf6a52ef3df1f 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -2,20 +2,55 @@ from __future__ import annotations +import asyncio +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohttp.web import Request +from monzopy import InvalidMonzoAPIResponseError + +from homeassistant.components import cloud +from homeassistant.components.webhook import ( + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from .api import AuthenticatedMonzoAPI -from .const import DOMAIN +from .const import DOMAIN, EVENT_TRANSACTION_CREATED, MONZO_EVENT from .coordinator import MonzoCoordinator +_LOGGER = logging.getLogger(__name__) + +CONF_CLOUDHOOK_URL = "cloudhook_url" +CONF_WEBHOOK_IDS = "webhook_ids" +WEBHOOK_ACTIVATION = "webhook_activation" +WEBHOOK_DEACTIVATION = "webhook_deactivation" +WEBHOOK_PUSH_TYPE = "push_type" +CLOUDHOOK_HOST = "hooks.nabu.casa" + PLATFORMS: list[Platform] = [Platform.SENSOR] +type MonzoConfigEntry = ConfigEntry[MonzoData] + + +@dataclass +class MonzoData: + """Runtime data stored in the MonzoConfigEntry.""" + + coordinator: MonzoCoordinator + webhook_ids: set[str] = field(default_factory=set) + cloudhook_urls: set[str] = field(default_factory=set) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -27,18 +62,156 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: external_api = AuthenticatedMonzoAPI(async_get_clientsession(hass), session) coordinator = MonzoCoordinator(hass, external_api) - await coordinator.async_config_entry_first_refresh() + entry.runtime_data = MonzoData(coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + webhook_manager = MonzoWebhookManager(hass, entry) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if state is cloud.CloudConnectionState.CLOUD_CONNECTED: + await webhook_manager.register_webhooks(None) + + if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: + await webhook_manager.unregister_webhooks() + async_call_later(hass, 30, webhook_manager.register_webhooks) + + if cloud.async_active_subscription(hass): + if cloud.async_is_connected(hass): + await webhook_manager.register_webhooks(None) + cloud.async_listen_connection_change(hass, manage_cloudhook) + elif hass.state == CoreState.running: + await webhook_manager.register_webhooks(None) + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, webhook_manager.register_webhooks + ) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class MonzoWebhookManager: + """Manages Monzo webhooks.""" + + _register_lock = asyncio.Lock() + + def __init__(self, hass: HomeAssistant, entry: MonzoConfigEntry) -> None: + """Initialise the webhook manager.""" + self.hass = hass + self.entry = entry + + async def register_webhooks(self, _: Any) -> None: + """Register webhooks for all Monzo accounts.""" + async with self._register_lock: + coordinator: MonzoCoordinator = self.entry.runtime_data.coordinator + await self.unregister_old_webhooks(coordinator) + for account in await coordinator.api.user_account.accounts(): + webhook_id = self.entry.entry_id + account["id"] + self.entry.runtime_data.webhook_ids.add(webhook_id) + + if cloud.async_active_subscription(self.hass): + webhook_url = await self._async_cloudhook_generate_url(webhook_id) + else: + webhook_url = webhook_generate_url(self.hass, webhook_id) + + if not webhook_url.startswith("https://"): + _LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_register( + self.hass, + DOMAIN, + "Monzo", + webhook_id, + async_handle_webhook, + ) + + try: + await coordinator.api.user_account.register_webhooks(webhook_url) + _LOGGER.info("Registered Monzo webhook: %s", webhook_url) + except InvalidMonzoAPIResponseError: + _LOGGER.error("Error during webhook registration") + else: + self.entry.async_on_unload(self.unregister_webhooks) + + async def unregister_old_webhooks(self, coordinator: MonzoCoordinator) -> None: + """Unregister any old webhooks associated with this client.""" + await coordinator.api.user_account.unregister_webhooks() + + async def _async_cloudhook_generate_url(self, webhook_id: str) -> str: + """Generate the full URL for a webhook_id.""" + webhook_url = await cloud.async_create_cloudhook(self.hass, webhook_id) + self.entry.runtime_data.cloudhook_urls.add(webhook_url) + return webhook_url + + async def unregister_webhooks(self) -> None: + """Unregister all Monzo webooks.""" + coordinator: MonzoCoordinator = self.entry.runtime_data.coordinator + + async_dispatcher_send( + self.hass, + f"signal-{DOMAIN}-webhook-None", + {"type": "None", "data": {WEBHOOK_PUSH_TYPE: WEBHOOK_DEACTIVATION}}, + ) + while self.entry.runtime_data.webhook_ids: + webhook_id = self.entry.runtime_data.webhook_ids.pop() + _LOGGER.debug("Unregister Monzo webhook (%s)", webhook_id) + webhook_unregister(self.hass, webhook_id) + + if cloud.async_active_subscription(self.hass): + try: + _LOGGER.debug("Removing Monzo cloudhook (%s)", webhook_id) + await cloud.async_delete_cloudhook(self.hass, webhook_id) + except cloud.CloudNotAvailable: + _LOGGER.error( + "Failed to remove Monzo cloudhook (%s) - cloud unavailable", + webhook_id, + ) + await self.unregister_old_webhooks(coordinator) + + +async def async_handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> None: + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError as err: + _LOGGER.error("Error in data: %s", err) + return + + _LOGGER.debug("Got webhook data: %s", data) + + event_type = data.get("type") + + if event_type == EVENT_TRANSACTION_CREATED: + async_send_event(hass, event_type, data.get("data")) + else: + _LOGGER.debug("Got unexpected event type from webhook: %s", event_type) + + +def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: + """Send events.""" + _LOGGER.debug("%s: %s", event_type, data) + if data and "account_id" in data: + async_dispatcher_send( + hass, + monzo_event_signal(event_type, data["account_id"]), + {"data": data}, + ) + else: + _LOGGER.error("Webhook data malformed: %s", data) + + +def monzo_event_signal(event_type: str, account_id: str) -> str: + """Generate a unique signal for a Monzo event.""" + return f"{MONZO_EVENT}_{event_type}_{account_id}" diff --git a/homeassistant/components/monzo/const.py b/homeassistant/components/monzo/const.py index 619daf120f7fc6..34e6d187146f40 100644 --- a/homeassistant/components/monzo/const.py +++ b/homeassistant/components/monzo/const.py @@ -1,3 +1,10 @@ """Constants for the Monzo integration.""" DOMAIN = "monzo" + +MODEL_POT = "Pot" + +MONZO_EVENT = "monzo_event" +EVENT_TRANSACTION_CREATED = "transaction.created" + +WEBHOOK_IDS = "webhook_ids" diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 223d7b05ffe0ed..9baef81b5dade2 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -18,14 +18,14 @@ @dataclass -class MonzoData: +class MonzoSensorData: """A dataclass for holding sensor data returned by the DataUpdateCoordinator.""" accounts: list[dict[str, Any]] pots: list[dict[str, Any]] -class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): +class MonzoCoordinator(DataUpdateCoordinator[MonzoSensorData]): """Class to manage fetching Monzo data from the API.""" def __init__(self, hass: HomeAssistant, api: AuthenticatedMonzoAPI) -> None: @@ -38,7 +38,7 @@ def __init__(self, hass: HomeAssistant, api: AuthenticatedMonzoAPI) -> None: ) self.api = api - async def _async_update_data(self) -> MonzoData: + async def _async_update_data(self) -> MonzoSensorData: """Fetch data from Monzo API.""" try: accounts = await self.api.user_account.accounts() @@ -46,4 +46,4 @@ async def _async_update_data(self) -> MonzoData: except AuthorisationExpiredError as err: raise ConfigEntryAuthFailed from err - return MonzoData(accounts, pots) + return MonzoSensorData(accounts, pots) diff --git a/homeassistant/components/monzo/device_trigger.py b/homeassistant/components/monzo/device_trigger.py new file mode 100644 index 00000000000000..4515d841ba392e --- /dev/null +++ b/homeassistant/components/monzo/device_trigger.py @@ -0,0 +1,96 @@ +"""Provides transaction creation triggers for Monzo.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import monzo_event_signal +from .const import DOMAIN, EVENT_TRANSACTION_CREATED, MODEL_POT + +TRIGGER_TYPES = [ + EVENT_TRANSACTION_CREATED, +] +ACCOUNT_ID = "account_id" + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(ACCOUNT_ID): cv.string, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device_registry = dr.async_get(hass) + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + if not device or device.model is None: + raise InvalidDeviceAutomationConfig( + f"Trigger invalid, device with ID {config[CONF_DEVICE_ID]} not found" + ) + + if device.model is MODEL_POT: + raise InvalidDeviceAutomationConfig( + f"Trigger invalid, device with ID {config[CONF_DEVICE_ID]} is a pot" + ) + + return config + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for Monzo devices.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + triggers = [] + + if device is not None: + triggers = [ + { + CONF_PLATFORM: CONF_DEVICE, + ACCOUNT_ID: next(iter(device.identifiers))[1], + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: trigger, + } + for trigger in TRIGGER_TYPES + ] + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + return async_dispatcher_connect( + hass, + monzo_event_signal(config[CONF_TYPE], config[ACCOUNT_ID]), + action, + ) diff --git a/homeassistant/components/monzo/entity.py b/homeassistant/components/monzo/entity.py index bf83e3a9bfbdb2..bdf86546b53069 100644 --- a/homeassistant/components/monzo/entity.py +++ b/homeassistant/components/monzo/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import MonzoCoordinator, MonzoData +from .coordinator import MonzoCoordinator, MonzoSensorData class MonzoBaseEntity(CoordinatorEntity[MonzoCoordinator]): @@ -23,7 +23,7 @@ def __init__( coordinator: MonzoCoordinator, index: int, device_model: str, - data_accessor: Callable[[MonzoData], list[dict[str, Any]]], + data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]], ) -> None: """Initialize sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index d9d17eb8abcf3c..2b5259004f70c3 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -1,9 +1,10 @@ { "domain": "monzo", "name": "Monzo", + "after_dependencies": ["cloud"], "codeowners": ["@jakemartin-icl"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", "requirements": ["monzopy==1.3.2"] diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index 41b97d90452e1c..a00f3bfd6f11d6 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -11,14 +11,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MonzoCoordinator -from .const import DOMAIN -from .coordinator import MonzoData +from . import MonzoConfigEntry, MonzoCoordinator +from .const import MODEL_POT +from .coordinator import MonzoSensorData from .entity import MonzoBaseEntity @@ -59,16 +58,14 @@ class MonzoSensorEntityDescription(SensorEntityDescription): ), ) -MODEL_POT = "Pot" - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MonzoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: MonzoCoordinator = config_entry.runtime_data.coordinator accounts = [ MonzoSensor( @@ -102,7 +99,7 @@ def __init__( entity_description: MonzoSensorEntityDescription, index: int, device_model: str, - data_accessor: Callable[[MonzoData], list[dict[str, Any]]], + data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]], ) -> None: """Initialize the sensor.""" super().__init__(coordinator, index, device_model, data_accessor) diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index e4ec34a845916e..f1a0896a43d505 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -31,6 +31,11 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "device_automation": { + "trigger_type": { + "transaction.created": "Transaction occured" + } + }, "entity": { "sensor": { "balance": { diff --git a/tests/components/monzo/__init__.py b/tests/components/monzo/__init__.py index db7321715213e4..e718dd0f959ffb 100644 --- a/tests/components/monzo/__init__.py +++ b/tests/components/monzo/__init__.py @@ -1,6 +1,7 @@ """Tests for the Monzo integration.""" from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from tests.common import MockConfigEntry @@ -9,4 +10,9 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) """Fixture for setting up the component.""" config_entry.add_to_hass(hass) + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/monzo/conftest.py b/tests/components/monzo/conftest.py index 451fd6b409d957..a1eee2039ff643 100644 --- a/tests/components/monzo/conftest.py +++ b/tests/components/monzo/conftest.py @@ -10,6 +10,7 @@ ClientCredential, async_import_client_credential, ) +from homeassistant.components.monzo import MonzoConfigEntry from homeassistant.components.monzo.api import AuthenticatedMonzoAPI from homeassistant.components.monzo.const import DOMAIN from homeassistant.core import HomeAssistant @@ -65,9 +66,9 @@ def mock_expires_at() -> int: @pytest.fixture -def polling_config_entry(expires_at: int) -> MockConfigEntry: +def polling_config_entry(expires_at: int) -> MonzoConfigEntry: """Create Monzo entry in Home Assistant.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, title=TITLE, unique_id=str(USER_ID), @@ -84,6 +85,8 @@ def polling_config_entry(expires_at: int) -> MockConfigEntry: "profile": TITLE, }, ) + entry.runtime_data = None + return entry @pytest.fixture(name="basic_monzo") diff --git a/tests/components/monzo/test_device_trigger.py b/tests/components/monzo/test_device_trigger.py new file mode 100644 index 00000000000000..be119a227bceca --- /dev/null +++ b/tests/components/monzo/test_device_trigger.py @@ -0,0 +1,161 @@ +"""Tests for the Monzo component.""" + +from unittest.mock import AsyncMock + +import pytest +from pytest_unordered import unordered + +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.monzo import async_send_event +from homeassistant.components.monzo.const import DOMAIN, EVENT_TRANSACTION_CREATED +from homeassistant.components.monzo.device_trigger import ( + ACCOUNT_ID, + async_validate_trigger_config, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .conftest import TEST_ACCOUNTS, TEST_POTS + +from tests.common import ( + MockConfigEntry, + async_get_device_automations, + async_mock_service, +) + + +@pytest.fixture +def automation_calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track automation calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +def _make_trigger(account_id: str, device_id: str) -> ConfigType: + return { + CONF_PLATFORM: CONF_DEVICE, + ACCOUNT_ID: account_id, + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: EVENT_TRANSACTION_CREATED, + "metadata": {}, + } + + +async def test_trigger_setup( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test all triggers set up for all devices.""" + await setup_integration(hass, polling_config_entry) + + for account in TEST_ACCOUNTS: + device = device_registry.async_get_device(identifiers={(DOMAIN, account["id"])}) + + expected_triggers = [_make_trigger(account["id"], device.id)] + + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + if trigger[CONF_DOMAIN] == DOMAIN + ] + + assert triggers == unordered(expected_triggers) + + +async def test_transaction_created_triggers_automation( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + automation_calls: list[ServiceCall], +) -> None: + """Test triggering an automation with transaction_created event.""" + await setup_integration(hass, polling_config_entry) + + account = TEST_ACCOUNTS[0] + test_amount = 123 + + device = device_registry.async_get_device(identifiers={(DOMAIN, account["id"])}) + + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: [ + { + "trigger": _make_trigger(account["id"], device.id), + "action": { + "service": "test.automation", + "data": {"amount": "{{ data.data.amount }}"}, + }, + }, + ] + }, + ) + + assert len(hass.states.async_entity_ids(AUTOMATION_DOMAIN)) == 1 + + event_data = { + CONF_TYPE: EVENT_TRANSACTION_CREATED, + "data": {"amount": test_amount}, + ACCOUNT_ID: account["id"], + } + + async_send_event(hass, EVENT_TRANSACTION_CREATED, event_data) + await hass.async_block_till_done() + + assert len(automation_calls) == 1 + assert automation_calls[0].data["amount"] == test_amount + + +async def test_trigger_validation_fails_if_not_valid_device( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, +) -> None: + """Test trigger validation fails if not valid device.""" + await setup_integration(hass, polling_config_entry) + + account = TEST_ACCOUNTS[0] + + trigger = _make_trigger(account["id"], "invalid_device_id") + + with pytest.raises(InvalidDeviceAutomationConfig): + await async_validate_trigger_config(hass, trigger) + + +async def test_trigger_validation_fails_if_pot( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test trigger validation fails if given device is a pot.""" + await setup_integration(hass, polling_config_entry) + + pot = TEST_POTS[0] + + device = device_registry.async_get_device(identifiers={(DOMAIN, pot["id"])}) + + trigger = _make_trigger(pot["id"], device.id) + + with pytest.raises(InvalidDeviceAutomationConfig): + await async_validate_trigger_config(hass, trigger) diff --git a/tests/components/monzo/test_init.py b/tests/components/monzo/test_init.py index b24fb6ff86e0d6..d293b81d501ab2 100644 --- a/tests/components/monzo/test_init.py +++ b/tests/components/monzo/test_init.py @@ -1,18 +1,37 @@ """Tests for component initialisation.""" +from dataclasses import dataclass from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch +from urllib.parse import urlparse +from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory -from monzopy import AuthorisationExpiredError +from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError +import pytest -from homeassistant.components.monzo.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.components import cloud +from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.components.monzo import MonzoConfigEntry, monzo_event_signal +from homeassistant.components.monzo.const import DOMAIN, EVENT_TRANSACTION_CREATED +from homeassistant.components.webhook import ( + DOMAIN as WEBHOOK_DOMAIN, + async_generate_url, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import setup_integration +from .conftest import TEST_ACCOUNTS -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_cloud_connection_status, +) +from tests.components.cloud import mock_cloud +from tests.typing import ClientSessionGenerator async def test_api_can_trigger_reauth( @@ -35,3 +54,303 @@ async def test_api_can_trigger_reauth( assert flow["step_id"] == "reauth_confirm" assert flow["handler"] == DOMAIN assert flow["context"]["source"] == SOURCE_REAUTH + + +@dataclass +class WebhookSetupData: + """A collection of data set up by the webhook_setup fixture.""" + + hass: HomeAssistant + client: TestClient + webhook_url: str + event_listener: Mock + + +@pytest.fixture +async def webhook_setup( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MonzoConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> WebhookSetupData: + """Set up integration, client and webhook url.""" + + await setup_integration(hass, polling_config_entry) + client = await hass_client_no_auth() + webhook_id = next(iter(polling_config_entry.runtime_data.webhook_ids)) + webhook_url = async_generate_url(hass, webhook_id) + event_listener = Mock() + async_dispatcher_connect( + hass, + monzo_event_signal(EVENT_TRANSACTION_CREATED, TEST_ACCOUNTS[0]["id"]), + event_listener, + ) + + return WebhookSetupData(hass, client, webhook_url, event_listener) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_webhook_fires_transaction_created( + webhook_setup: WebhookSetupData, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test calling a webhook fires transaction_created event.""" + + resp = await webhook_setup.client.post( + urlparse(webhook_setup.webhook_url).path, + json={ + "type": EVENT_TRANSACTION_CREATED, + "data": {"account_id": TEST_ACCOUNTS[0]["id"]}, + }, + ) + # Wait for remaining tasks to complete. + await webhook_setup.hass.async_block_till_done() + + assert resp.ok + webhook_setup.event_listener.assert_called_once() + + resp.close() + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_webhook_with_unexpected_type( + webhook_setup: WebhookSetupData, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test calling a webhook with an unexpected event type.""" + + resp = await webhook_setup.client.post( + urlparse(webhook_setup.webhook_url).path, + json={ + "type": "fail", + "data": {"account_id": TEST_ACCOUNTS[0]["id"]}, + }, + ) + # Wait for remaining tasks to complete. + await webhook_setup.hass.async_block_till_done() + + assert resp.ok + webhook_setup.event_listener.assert_not_called() + + assert "unexpected event type" in caplog.text + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_webhook_failure_with_missing_data( + webhook_setup: WebhookSetupData, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test calling a webhook with missing data.""" + resp = await webhook_setup.client.post( + urlparse(webhook_setup.webhook_url).path, + json={"type": EVENT_TRANSACTION_CREATED}, + ) + # Wait for remaining tasks to complete. + await webhook_setup.hass.async_block_till_done() + + assert "Webhook data malformed" in caplog.text + + resp.close() + + +async def test_webhook_failure_with_invalid_json( + webhook_setup: WebhookSetupData, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test calling a webhook with invalid JSON.""" + resp = await webhook_setup.client.post( + urlparse(webhook_setup.webhook_url).path, + data="invalid", + ) + # Wait for remaining tasks to complete. + await webhook_setup.hass.async_block_till_done() + + assert "Error in data" in caplog.text + + resp.close() + + +async def test_webhook_fails_with_invalid_monzo_api_response( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MonzoConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test calling a webhook with an invalid Monzo API response.""" + monzo.user_account.register_webhooks.side_effect = InvalidMonzoAPIResponseError() + await setup_integration(hass, polling_config_entry) + await hass.async_block_till_done() + assert "Error during webhook registration" in caplog.text + + +async def test_cloudhook( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MonzoConfigEntry, +) -> None: + """Test cloudhook setup.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.monzo.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + patch("homeassistant.components.monzo.webhook_generate_url"), + ): + await setup_integration(hass, polling_config_entry) + + assert cloud.async_active_subscription(hass) is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + assert fake_create_cloudhook.call_count == len(TEST_ACCOUNTS) + assert len(hass.data[WEBHOOK_DOMAIN]) == len(TEST_ACCOUNTS) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.call_count = len(TEST_ACCOUNTS) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + assert len(hass.data[WEBHOOK_DOMAIN]) == 0 + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.monzo.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), + patch( + "homeassistant.components.monzo.webhook_generate_url", + ), + ): + await setup_integration(hass, polling_config_entry) + + assert cloud.async_active_subscription(hass) is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + assert ( + hass.config_entries.async_entries(DOMAIN)[0].state + == ConfigEntryState.LOADED + ) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + assert "Failed to remove Monzo cloudhook" in caplog.text + + +async def test_webhook_fails_without_https( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if set up with cloud link and without https.""" + hass.config.components.add("cloud") + with ( + patch( + "homeassistant.helpers.network.get_url", + return_value="http://example.nabu.casa", + ), + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.monzo.webhook_generate_url" + ) as mock_async_generate_url, + ): + mock_async_generate_url.return_value = "http://example.com" + await setup_integration(hass, polling_config_entry) + + await hass.async_block_till_done() + mock_async_generate_url.call_count = len(TEST_ACCOUNTS) + + assert "https and port 443 is required to register the webhook" in caplog.text + + +async def test_cloud_disconnect_retry( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MonzoConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we retry to create webhook connection again after cloud disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object( + cloud, "async_active_subscription", return_value=True + ) as mock_async_active_subscription, + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.monzo.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.monzo.webhook_generate_url", + ), + ): + await setup_integration(hass, polling_config_entry) + # await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert mock_async_active_subscription.call_count == 4 + + await hass.async_block_till_done() + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert mock_async_active_subscription.call_count == 6 + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_async_active_subscription.call_count == 8