Skip to content

Commit

Permalink
Fix mobile_app cloudhook creation (home-assistant#107068)
Browse files Browse the repository at this point in the history
  • Loading branch information
edenhaus authored Jan 5, 2024
1 parent 6da82cf commit c063bf4
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 17 deletions.
17 changes: 17 additions & 0 deletions homeassistant/components/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
from typing import cast

from hass_nabucasa import Cloud
import voluptuous as vol
Expand Down Expand Up @@ -176,6 +177,22 @@ def async_active_subscription(hass: HomeAssistant) -> bool:
return async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired


async def async_get_or_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str:
"""Get or create a cloudhook."""
if not async_is_connected(hass):
raise CloudNotConnected

if not async_is_logged_in(hass):
raise CloudNotAvailable

cloud: Cloud[CloudClient] = hass.data[DOMAIN]
cloudhooks = cloud.client.cloudhooks
if hook := cloudhooks.get(webhook_id):
return cast(str, hook["cloudhook_url"])

return await async_create_cloudhook(hass, webhook_id)


@bind_hass
async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str:
"""Create a cloudhook."""
Expand Down
13 changes: 4 additions & 9 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
from .helpers import savable_state
from .http_api import RegistrationsView
from .util import async_create_cloud_hook
from .webhook import handle_webhook

PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER]
Expand Down Expand Up @@ -103,26 +104,20 @@ 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)

async def create_cloud_hook() -> None:
"""Create a cloud hook."""
hook = await cloud.async_create_cloudhook(hass, webhook_id)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook}
)

async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data
):
await create_cloud_hook()
await async_create_cloud_hook(hass, webhook_id, entry)

if (
CONF_CLOUDHOOK_URL not in entry.data
and cloud.async_active_subscription(hass)
and cloud.async_is_connected(hass)
):
await create_cloud_hook()
await async_create_cloud_hook(hass, webhook_id, entry)

entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
5 changes: 3 additions & 2 deletions homeassistant/components/mobile_app/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SCHEMA_APP_DATA,
)
from .helpers import supports_encryption
from .util import async_create_cloud_hook


class RegistrationsView(HomeAssistantView):
Expand Down Expand Up @@ -69,8 +70,8 @@ async def post(self, request: Request, data: dict) -> Response:
webhook_id = secrets.token_hex()

if cloud.async_active_subscription(hass):
data[CONF_CLOUDHOOK_URL] = await cloud.async_create_cloudhook(
hass, webhook_id
data[CONF_CLOUDHOOK_URL] = await async_create_cloud_hook(
hass, webhook_id, None
)

data[CONF_WEBHOOK_ID] = webhook_id
Expand Down
20 changes: 20 additions & 0 deletions homeassistant/components/mobile_app/util.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Mobile app utility functions."""
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING

from homeassistant.components import cloud
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback

from .const import (
ATTR_APP_DATA,
ATTR_PUSH_TOKEN,
ATTR_PUSH_URL,
ATTR_PUSH_WEBSOCKET_CHANNEL,
CONF_CLOUDHOOK_URL,
DATA_CONFIG_ENTRIES,
DATA_DEVICES,
DATA_NOTIFY,
Expand Down Expand Up @@ -53,3 +57,19 @@ def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None:
return target_service

return None


_CLOUD_HOOK_LOCK = asyncio.Lock()


async def async_create_cloud_hook(
hass: HomeAssistant, webhook_id: str, entry: ConfigEntry | None
) -> str:
"""Create a cloud hook."""
async with _CLOUD_HOOK_LOCK:
hook = await cloud.async_get_or_create_cloudhook(hass, webhook_id)
if entry:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook}
)
return hook
1 change: 1 addition & 0 deletions tests/components/cloud/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def mock_is_connected() -> bool:

is_connected = PropertyMock(side_effect=mock_is_connected)
type(mock_cloud).is_connected = is_connected
type(mock_cloud.iot).connected = is_connected

# Properties that we mock as attributes.
mock_cloud.expiration_date = utcnow()
Expand Down
64 changes: 62 additions & 2 deletions tests/components/cloud/test_init.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Test the cloud component."""
from collections.abc import Callable, Coroutine
from typing import Any
from unittest.mock import patch
from unittest.mock import MagicMock, patch

from hass_nabucasa import Cloud
import pytest

from homeassistant.components import cloud
from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.cloud import (
CloudNotAvailable,
CloudNotConnected,
async_get_or_create_cloudhook,
)
from homeassistant.components.cloud.const import DOMAIN, PREF_CLOUDHOOKS
from homeassistant.components.cloud.prefs import STORAGE_KEY
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Context, HomeAssistant
Expand Down Expand Up @@ -214,3 +220,57 @@ async def test_remote_ui_url(hass: HomeAssistant, mock_cloud_fixture) -> None:
cl.client.prefs._prefs["remote_domain"] = "example.com"

assert cloud.async_remote_ui_url(hass) == "https://example.com"


async def test_async_get_or_create_cloudhook(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
) -> None:
"""Test async_get_or_create_cloudhook."""
assert await async_setup_component(hass, "cloud", {"cloud": {}})
await hass.async_block_till_done()

webhook_id = "mock-webhook-id"
cloudhook_url = "https://cloudhook.nabu.casa/abcdefg"

with patch(
"homeassistant.components.cloud.async_create_cloudhook",
return_value=cloudhook_url,
) as async_create_cloudhook_mock:
# create cloudhook as it does not exist
assert (await async_get_or_create_cloudhook(hass, webhook_id)) == cloudhook_url
async_create_cloudhook_mock.assert_called_once_with(hass, webhook_id)

await set_cloud_prefs(
{
PREF_CLOUDHOOKS: {
webhook_id: {
"webhook_id": webhook_id,
"cloudhook_id": "random-id",
"cloudhook_url": cloudhook_url,
"managed": True,
}
}
}
)

async_create_cloudhook_mock.reset_mock()

# get cloudhook as it exists
assert await async_get_or_create_cloudhook(hass, webhook_id) == cloudhook_url
async_create_cloudhook_mock.assert_not_called()

# Simulate logged out
cloud.id_token = None

# Not logged in
with pytest.raises(CloudNotAvailable):
await async_get_or_create_cloudhook(hass, webhook_id)

# Simulate disconnected
cloud.iot.state = "disconnected"

# Not connected
with pytest.raises(CloudNotConnected):
await async_get_or_create_cloudhook(hass, webhook_id)
10 changes: 6 additions & 4 deletions tests/components/mobile_app/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,17 @@ async def _test_create_cloud_hook(
), patch(
"homeassistant.components.cloud.async_is_connected", return_value=True
), patch(
"homeassistant.components.cloud.async_create_cloudhook", autospec=True
) as mock_create_cloudhook:
"homeassistant.components.cloud.async_get_or_create_cloudhook", autospec=True
) as mock_async_get_or_create_cloudhook:
cloud_hook = "https://hook-url"
mock_create_cloudhook.return_value = cloud_hook
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
await additional_steps(config_entry, mock_create_cloudhook, cloud_hook)
await additional_steps(
config_entry, mock_async_get_or_create_cloudhook, cloud_hook
)


async def test_create_cloud_hook_on_setup(
Expand Down

0 comments on commit c063bf4

Please sign in to comment.