diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 05e420cb4143af..5377075fe545e5 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from enum import Enum import logging -from typing import cast +from typing import Any, cast from hass_nabucasa import Cloud import voluptuous as vol @@ -86,6 +86,10 @@ "CLOUD_CONNECTION_STATE" ) +_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType( + "CLOUDHOOKS_UPDATED" +) + STARTUP_REPAIR_DELAY = 1 # 1 hour ALEXA_ENTITY_SCHEMA = vol.Schema( @@ -242,6 +246,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None: await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id) +@callback +def async_listen_cloudhook_change( + hass: HomeAssistant, + webhook_id: str, + on_change: Callable[[dict[str, Any] | None], None], +) -> Callable[[], None]: + """Listen for cloudhook changes for the given webhook and notify when modified or deleted.""" + + @callback + def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None: + """Handle cloudhooks updated signal.""" + on_change(cloudhooks.get(webhook_id)) + + return async_dispatcher_connect( + hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated + ) + + @bind_hass @callback def async_remote_ui_url(hass: HomeAssistant) -> str: @@ -289,7 +311,7 @@ async def _shutdown(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) - _remote_handle_prefs_updated(cloud) + _handle_prefs_updated(hass, cloud) _setup_services(hass, prefs) async def async_startup_repairs(_: datetime) -> None: @@ -373,26 +395,32 @@ async def _on_initialized() -> None: @callback -def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: - """Handle remote preferences updated.""" - cur_pref = cloud.client.prefs.remote_enabled +def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: + """Register handler for cloud preferences updates.""" + cur_remote_enabled = cloud.client.prefs.remote_enabled + cur_cloudhooks = cloud.client.prefs.cloudhooks lock = asyncio.Lock() - # Sync remote connection with prefs - async def remote_prefs_updated(prefs: CloudPreferences) -> None: - """Update remote status.""" - nonlocal cur_pref + async def on_prefs_updated(prefs: CloudPreferences) -> None: + """Handle cloud preferences updates.""" + nonlocal cur_remote_enabled + nonlocal cur_cloudhooks + # Lock protects cur_ state variables from concurrent updates async with lock: - if prefs.remote_enabled == cur_pref: + if cur_cloudhooks != prefs.cloudhooks: + cur_cloudhooks = prefs.cloudhooks + async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks) + + if prefs.remote_enabled == cur_remote_enabled: return - if cur_pref := prefs.remote_enabled: + if cur_remote_enabled := prefs.remote_enabled: await cloud.remote.connect() else: await cloud.remote.disconnect() - cloud.client.prefs.async_listen_updates(remote_prefs_updated) + cloud.client.prefs.async_listen_updates(on_prefs_updated) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 64b367112efd95..65c8296264a53d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -131,12 +131,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) + def clean_cloudhook() -> None: + """Clean up cloudhook from config entry.""" + if CONF_CLOUDHOOK_URL in entry.data: + data = dict(entry.data) + data.pop(CONF_CLOUDHOOK_URL) + hass.config_entries.async_update_entry(entry, data=data) + + def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None: + """Handle cloudhook changes.""" + if cloudhook: + if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]: + return + + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]}, + ) + else: + clean_cloudhook() + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: if ( state is cloud.CloudConnectionState.CLOUD_CONNECTED and CONF_CLOUDHOOK_URL not in entry.data ): await async_create_cloud_hook(hass, webhook_id, entry) + elif ( + state is cloud.CloudConnectionState.CLOUD_DISCONNECTED + and not cloud.async_is_logged_in(hass) + ): + clean_cloudhook() + + entry.async_on_unload( + cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change) + ) if cloud.async_is_logged_in(hass): if ( @@ -147,9 +176,7 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: await async_create_cloud_hook(hass, webhook_id, entry) elif CONF_CLOUDHOOK_URL in entry.data: # If we have a cloudhook but no longer logged in to the cloud, remove it from the entry - data = dict(entry.data) - data.pop(CONF_CLOUDHOOK_URL) - hass.config_entries.async_update_entry(entry, data=data) + clean_cloudhook() entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 102182026683d7..da6c6676b4f358 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -756,10 +756,9 @@ async def webhook_get_config( "theme_color": MANIFEST_JSON["theme_color"], } - if CONF_CLOUDHOOK_URL in config_entry.data: - resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL] - if cloud.async_active_subscription(hass): + if CONF_CLOUDHOOK_URL in config_entry.data: + resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL] with suppress(cloud.CloudNotAvailable): resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 087634c5173eb8..71ff04d9f3a70a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -11,6 +11,7 @@ CloudNotAvailable, CloudNotConnected, async_get_or_create_cloudhook, + async_listen_cloudhook_change, async_listen_connection_change, async_remote_ui_url, ) @@ -311,3 +312,149 @@ async def test_cloud_logout( await hass.async_block_till_done() assert cloud.is_logged_in is False + + +async def test_async_listen_cloudhook_change( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test async_listen_cloudhook_change.""" + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + + webhook_id = "mock-webhook-id" + cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" + + # Set up initial cloudhooks state + await set_cloud_prefs( + { + PREF_CLOUDHOOKS: { + webhook_id: { + "webhook_id": webhook_id, + "cloudhook_id": "random-id", + "cloudhook_url": cloudhook_url, + "managed": True, + } + } + } + ) + + # Track cloudhook changes + changes = [] + changeInvoked = False + + def on_change(cloudhook: dict[str, Any] | None) -> None: + """Handle cloudhook change.""" + nonlocal changeInvoked + changes.append(cloudhook) + changeInvoked = True + + # Register the change listener + unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change) + + # Verify no changes yet + assert len(changes) == 0 + assert changeInvoked is False + + # Delete the cloudhook by updating prefs + await set_cloud_prefs({PREF_CLOUDHOOKS: {}}) + await hass.async_block_till_done() + + # Verify deletion callback was called with None + assert len(changes) == 1 + assert changes[-1] is None + assert changeInvoked is True + + # Reset changeInvoked to detect next change + changeInvoked = False + + # Add cloudhook back + cloudhook_data = { + "webhook_id": webhook_id, + "cloudhook_id": "random-id", + "cloudhook_url": cloudhook_url, + "managed": True, + } + await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}}) + await hass.async_block_till_done() + + # Verify callback called with cloudhook data + assert len(changes) == 2 + assert changes[-1] == cloudhook_data + assert changeInvoked is True + + # Reset changeInvoked to detect next change + changeInvoked = False + + # Update cloudhook data with same cloudhook should not trigger callback + await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}}) + await hass.async_block_till_done() + + assert changeInvoked is False + + # Unsubscribe from listener + unsubscribe() + + # Delete cloudhook again + await set_cloud_prefs({PREF_CLOUDHOOKS: {}}) + await hass.async_block_till_done() + + # Verify change callback was NOT called after unsubscribe + assert len(changes) == 2 + assert changeInvoked is False + + +async def test_async_listen_cloudhook_change_cloud_setup_later( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test async_listen_cloudhook_change works when cloud is set up after listener registration.""" + webhook_id = "mock-webhook-id" + cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" + + # Track cloudhook changes + changes: list[dict[str, Any] | None] = [] + + def on_change(cloudhook: dict[str, Any] | None) -> None: + """Handle cloudhook change.""" + changes.append(cloudhook) + + # Register listener BEFORE cloud is set up + unsubscribe = async_listen_cloudhook_change(hass, webhook_id, on_change) + + # Verify it returns a callable + assert callable(unsubscribe) + + # No changes yet since cloud isn't set up + assert len(changes) == 0 + + # Now set up cloud + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + + # Add a cloudhook - this should trigger the listener + cloudhook_data = { + "webhook_id": webhook_id, + "cloudhook_id": "random-id", + "cloudhook_url": cloudhook_url, + "managed": True, + } + await set_cloud_prefs({PREF_CLOUDHOOKS: {webhook_id: cloudhook_data}}) + await hass.async_block_till_done() + + # Verify the listener received the update + assert len(changes) == 1 + assert changes[-1] == cloudhook_data + + # Unsubscribe and verify no more updates + unsubscribe() + + await set_cloud_prefs({PREF_CLOUDHOOKS: {}}) + await hass.async_block_till_done() + + # Should not receive update after unsubscribe + assert len(changes) == 1 diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index a4edbea6ecf8ba..7b541f3d276807 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -68,7 +68,9 @@ async def _test_create_cloud_hook( hass_admin_user: MockUser, additional_config: dict[str, Any], async_active_subscription_return_value: bool, - additional_steps: Callable[[ConfigEntry, Mock, str], Awaitable[None]], + additional_steps: Callable[ + [ConfigEntry, Mock, str, Callable[[Any], None]], Awaitable[None] + ], ) -> None: config_entry = MockConfigEntry( data={ @@ -84,6 +86,24 @@ async def _test_create_cloud_hook( ) config_entry.add_to_hass(hass) + cloudhook_change_callback = None + + def mock_listen_cloudhook_change( + _: HomeAssistant, _webhook_id: str, callback: Callable[[Any], None] + ): + """Mock the cloudhook change listener.""" + nonlocal cloudhook_change_callback + cloudhook_change_callback = callback + return lambda: None # Return unsubscribe function + + cloud_hook = "https://hook-url" + + async def mock_get_or_create_cloudhook(_hass: HomeAssistant, _webhook_id: str): + """Mock creating a cloudhook and trigger the change callback.""" + assert cloudhook_change_callback is not None + cloudhook_change_callback({CONF_CLOUDHOOK_URL: cloud_hook}) + return cloud_hook + with ( patch( "homeassistant.components.cloud.async_active_subscription", @@ -93,17 +113,24 @@ async def _test_create_cloud_hook( patch("homeassistant.components.cloud.async_is_connected", return_value=True), patch( "homeassistant.components.cloud.async_get_or_create_cloudhook", - autospec=True, + side_effect=mock_get_or_create_cloudhook, ) as mock_async_get_or_create_cloudhook, + patch( + "homeassistant.components.cloud.async_listen_cloudhook_change", + side_effect=mock_listen_cloudhook_change, + ), ): - cloud_hook = "https://hook-url" - mock_async_get_or_create_cloudhook.return_value = cloud_hook - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + + assert cloudhook_change_callback is not None + await additional_steps( - config_entry, mock_async_get_or_create_cloudhook, cloud_hook + config_entry, + mock_async_get_or_create_cloudhook, + cloud_hook, + cloudhook_change_callback, ) @@ -114,7 +141,10 @@ async def test_create_cloud_hook_on_setup( """Test creating a cloud hook during setup.""" async def additional_steps( - config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + config_entry: ConfigEntry, + mock_create_cloudhook: Mock, + cloud_hook: str, + cloudhook_change_callback: Callable[[Any], None], ) -> None: assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook mock_create_cloudhook.assert_called_once_with( @@ -134,7 +164,10 @@ async def test_remove_cloudhook( """Test removing a cloud hook when config entry is removed.""" async def additional_steps( - config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + config_entry: ConfigEntry, + mock_create_cloudhook: Mock, + cloud_hook: str, + cloudhook_change_callback: Callable[[Any], None], ) -> None: webhook_id = config_entry.data[CONF_WEBHOOK_ID] assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook @@ -158,7 +191,10 @@ async def test_create_cloud_hook_aleady_exists( cloud_hook = "https://hook-url-already-exists" async def additional_steps( - config_entry: ConfigEntry, mock_create_cloudhook: Mock, _: str + config_entry: ConfigEntry, + mock_create_cloudhook: Mock, + _: str, + cloudhook_change_callback: Callable[[Any], None], ) -> None: assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook mock_create_cloudhook.assert_not_called() @@ -175,13 +211,21 @@ async def test_create_cloud_hook_after_connection( """Test creating a cloud hook when connected to the cloud.""" async def additional_steps( - config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + config_entry: ConfigEntry, + mock_create_cloudhook: Mock, + cloud_hook: str, + cloudhook_change_callback: Callable[[Any], None], ) -> None: assert CONF_CLOUDHOOK_URL not in config_entry.data mock_create_cloudhook.assert_not_called() async_mock_cloud_connection_status(hass, True) await hass.async_block_till_done() + + # Simulate cloudhook creation by calling the callback + cloudhook_change_callback({CONF_CLOUDHOOK_URL: cloud_hook}) + await hass.async_block_till_done() + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook mock_create_cloudhook.assert_called_once_with( hass, config_entry.data[CONF_WEBHOOK_ID] @@ -260,3 +304,236 @@ async def test_remove_entry_on_user_remove( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 0 + + +async def test_cloudhook_cleanup_on_disconnect_and_logout( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test cloudhook is cleaned up when cloud disconnects and user is logged out.""" + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: "test-webhook-id", + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + CONF_CLOUDHOOK_URL: "https://hook-url", + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.cloud.async_is_logged_in", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_is_connected", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + # Cloudhook should still exist + assert CONF_CLOUDHOOK_URL in config_entry.data + + # Simulate cloud disconnect and logout + with patch( + "homeassistant.components.cloud.async_is_logged_in", + return_value=False, + ): + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + # Cloudhook should be removed from config entry + assert CONF_CLOUDHOOK_URL not in config_entry.data + + +async def test_cloudhook_persists_on_disconnect_when_logged_in( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test cloudhook persists when cloud disconnects but user is still logged in.""" + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: "test-webhook-id", + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + CONF_CLOUDHOOK_URL: "https://hook-url", + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.cloud.async_is_logged_in", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_is_connected", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + # Cloudhook should exist + assert CONF_CLOUDHOOK_URL in config_entry.data + + # Simulate cloud disconnect while still logged in + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + # Cloudhook should still exist because user is still logged in + assert CONF_CLOUDHOOK_URL in config_entry.data + + +async def test_cloudhook_change_listener_deletion( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test cloudhook change listener removes cloudhook from config entry on deletion.""" + webhook_id = "test-webhook-id" + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: webhook_id, + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + CONF_CLOUDHOOK_URL: "https://hook-url", + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + cloudhook_change_callback = None + + def mock_listen_cloudhook_change( + _: HomeAssistant, _webhook_id: str, callback: Callable[[Any], None] + ): + """Mock the cloudhook change listener.""" + nonlocal cloudhook_change_callback + cloudhook_change_callback = callback + return lambda: None # Return unsubscribe function + + with ( + patch( + "homeassistant.components.cloud.async_is_logged_in", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_is_connected", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_listen_cloudhook_change", + side_effect=mock_listen_cloudhook_change, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + # Cloudhook should exist + assert CONF_CLOUDHOOK_URL in config_entry.data + # Change listener should have been registered + assert cloudhook_change_callback is not None + + # Simulate cloudhook deletion by calling the callback with None + cloudhook_change_callback(None) + await hass.async_block_till_done() + + # Cloudhook should be removed from config entry + assert CONF_CLOUDHOOK_URL not in config_entry.data + + +async def test_cloudhook_change_listener_update( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test cloudhook change listener updates cloudhook URL in config entry.""" + webhook_id = "test-webhook-id" + original_url = "https://hook-url" + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: webhook_id, + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + CONF_CLOUDHOOK_URL: original_url, + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + cloudhook_change_callback = None + + def mock_listen_cloudhook_change(hass_instance, wh_id: str, callback): + """Mock the cloudhook change listener.""" + nonlocal cloudhook_change_callback + cloudhook_change_callback = callback + return lambda: None # Return unsubscribe function + + with ( + patch( + "homeassistant.components.cloud.async_is_logged_in", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_is_connected", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_listen_cloudhook_change", + side_effect=mock_listen_cloudhook_change, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + # Cloudhook should exist with original URL + assert config_entry.data[CONF_CLOUDHOOK_URL] == original_url + # Change listener should have been registered + assert cloudhook_change_callback is not None + + # Simulate cloudhook URL change + new_url = "https://new-hook-url" + cloudhook_change_callback({CONF_CLOUDHOOK_URL: new_url}) + await hass.async_block_till_done() + + # Cloudhook URL should be updated in config entry + assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url + + # Simulate same URL update (should not trigger update) + cloudhook_change_callback({CONF_CLOUDHOOK_URL: new_url}) + await hass.async_block_till_done() + + # URL should remain the same + assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index b071caebd16e2a..e0c5a1cf77cd43 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -310,6 +310,73 @@ async def test_webhook_handle_get_config( assert expected_dict == json +async def test_webhook_handle_get_config_with_cloudhook_and_active_subscription( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test get_config returns cloudhook_url when there's an active subscription.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Get the config entry and add cloudhook_url to it + config_entry = hass.config_entries.async_entries(DOMAIN)[1] + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, "cloudhook_url": "https://hooks.nabu.casa/test"}, + ) + + with ( + patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=True, + ), + patch( + "homeassistant.components.cloud.async_remote_ui_url", + return_value="https://remote.ui.url", + ), + ): + resp = await webhook_client.post(webhook_url, json={"type": "get_config"}) + assert resp.status == HTTPStatus.OK + json_resp = await resp.json() + + # Cloudhook should be in response + assert "cloudhook_url" in json_resp + assert json_resp["cloudhook_url"] == "https://hooks.nabu.casa/test" + # Remote UI should also be in response + assert "remote_ui_url" in json_resp + + +async def test_webhook_handle_get_config_with_cloudhook_no_subscription( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test get_config doesn't return cloudhook_url without active subscription.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Get the config entry and add cloudhook_url to it + config_entry = hass.config_entries.async_entries(DOMAIN)[1] + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, "cloudhook_url": "https://hooks.nabu.casa/test"}, + ) + + with patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=False, + ): + resp = await webhook_client.post(webhook_url, json={"type": "get_config"}) + assert resp.status == HTTPStatus.OK + json_resp = await resp.json() + + # Cloudhook should NOT be in response even though it exists in config entry + assert "cloudhook_url" not in json_resp + # Remote UI should also not be in response + assert "remote_ui_url" not in json_resp + + async def test_webhook_returns_error_incorrect_json( create_registrations: tuple[dict[str, Any], dict[str, Any]], webhook_client: TestClient,