diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 97d2345f16b..3b8cfb08593 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -30,6 +30,7 @@ PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" +PREF_GOOGLE_CONNECTED = "google_connected" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 98dee500421..12d3453a53c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -258,17 +258,6 @@ class CloudGoogleConfig(AbstractConfig): self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) - # Remove any stored user agent id that is not ours - remove_agent_user_ids = [] - for agent_user_id in self._store.agent_user_ids: - if agent_user_id != self.agent_user_id: - remove_agent_user_ids.append(agent_user_id) - - if remove_agent_user_ids: - _LOGGER.debug("remove non cloud agent_user_ids: %s", remove_agent_user_ids) - for agent_user_id in remove_agent_user_ids: - await self.async_disconnect_agent_user(agent_user_id) - self._on_deinitialize.append( self._prefs.async_listen_updates(self._async_prefs_updated) ) @@ -339,7 +328,7 @@ class CloudGoogleConfig(AbstractConfig): @property def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" - return len(self._store.agent_user_ids) > 0 + return len(self.async_get_agent_users()) > 0 def get_agent_user_id(self, context: Any) -> str: """Get agent user ID making request.""" @@ -380,6 +369,30 @@ class CloudGoogleConfig(AbstractConfig): resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status + async def async_connect_agent_user(self, agent_user_id: str) -> None: + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + await self._prefs.async_update(google_connected=True) + + async def async_disconnect_agent_user(self, agent_user_id: str) -> None: + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + await self._prefs.async_update(google_connected=False) + + @callback + def async_get_agent_users(self) -> tuple: + """Return known agent users.""" + if not self._prefs.google_connected or not self._cloud.username: + return () + return (self._cloud.username,) + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" _LOGGER.debug("_async_prefs_updated") diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index af5f9213e4d..1664966f985 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -8,6 +8,9 @@ import uuid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook +from homeassistant.components.google_assistant.http import ( + async_get_users as async_get_google_assistant_users, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -28,6 +31,7 @@ from .const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, + PREF_GOOGLE_CONNECTED, PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_ENTITY_CONFIGS, PREF_GOOGLE_LOCAL_WEBHOOK_ID, @@ -42,7 +46,7 @@ from .const import ( STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 ALEXA_SETTINGS_VERSION = 3 GOOGLE_SETTINGS_VERSION = 3 @@ -55,10 +59,27 @@ class CloudPreferencesStore(Store): self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] ) -> dict[str, Any]: """Migrate to the new version.""" + + async def google_connected() -> bool: + """Return True if our user is preset in the google_assistant store.""" + # If we don't have a user, we can't be connected to Google + if not (cur_username := old_data.get(PREF_USERNAME)): + return False + + # If our user is in the Google store, we're connected + return cur_username in await async_get_google_assistant_users(self.hass) + if old_major_version == 1: if old_minor_version < 2: old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + if old_minor_version < 3: + # Import settings from the google_assistant store which was previously + # shared between the cloud integration and manually configured Google + # assistant. + # In HA Core 2024.9, remove the import and also remove the Google + # assistant store if it's not been migrated by manual Google assistant + old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected()) return old_data @@ -131,6 +152,7 @@ class CloudPreferences: remote_domain: str | None | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED, + google_connected: bool | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -148,6 +170,7 @@ class CloudPreferences: (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), + (PREF_GOOGLE_CONNECTED, google_connected), ): if value is not UNDEFINED: prefs[key] = value @@ -241,6 +264,12 @@ class CloudPreferences: google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] return google_enabled + @property + def google_connected(self) -> bool: + """Return if Google is connected.""" + google_connected: bool = self._prefs[PREF_GOOGLE_CONNECTED] + return google_connected + @property def google_report_state(self) -> bool: """Return if Google report state is enabled.""" @@ -338,6 +367,7 @@ class CloudPreferences: PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, + PREF_GOOGLE_CONNECTED: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 65782c9ec24..2aabaa59d01 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from asyncio import gather -from collections.abc import Callable, Mapping +from collections.abc import Callable, Collection, Mapping from datetime import datetime, timedelta from functools import lru_cache from http import HTTPStatus @@ -33,7 +33,6 @@ from homeassistant.helpers import ( from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.redact import partial_redact -from homeassistant.helpers.storage import Store from homeassistant.util.dt import utcnow from . import trait @@ -46,8 +45,6 @@ from .const import ( ERR_FUNCTION_NOT_SUPPORTED, NOT_EXPOSE_LOCAL, SOURCE_LOCAL, - STORE_AGENT_USER_IDS, - STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from .data_redaction import async_redact_request_msg, async_redact_response_msg from .error import SmartHomeError @@ -94,7 +91,6 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" - _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None def __init__(self, hass: HomeAssistant) -> None: @@ -109,9 +105,6 @@ class AbstractConfig(ABC): async def async_initialize(self) -> None: """Perform async initialization of config.""" - self._store = GoogleConfigStore(self.hass) - await self._store.async_initialize() - if not self.enabled: return @@ -203,7 +196,7 @@ class AbstractConfig(ABC): """Send a state report to Google for all previously synced users.""" jobs = [ self.async_report_state(message, agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ] await gather(*jobs) @@ -235,13 +228,13 @@ class AbstractConfig(ABC): async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return 204 res = await gather( *( self.async_sync_entities(agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=204) @@ -262,13 +255,13 @@ class AbstractConfig(ABC): self, event_id: str, payload: dict[str, Any] ) -> HTTPStatus: """Sync notification to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return HTTPStatus.NO_CONTENT res = await gather( *( self.async_sync_notification(agent_user_id, event_id, payload) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=HTTPStatus.NO_CONTENT) @@ -291,7 +284,7 @@ class AbstractConfig(ABC): @callback def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): self.async_schedule_google_sync(agent_user_id) async def _async_request_sync_devices(self, agent_user_id: str) -> int: @@ -301,13 +294,14 @@ class AbstractConfig(ABC): """ raise NotImplementedError + @abstractmethod async def async_connect_agent_user(self, agent_user_id: str): """Add a synced and known agent_user_id. Called before sending a sync response to Google. """ - self._store.add_agent_user_id(agent_user_id) + @abstractmethod async def async_disconnect_agent_user(self, agent_user_id: str): """Turn off report state and disable further state reporting. @@ -316,7 +310,11 @@ class AbstractConfig(ABC): - When the cloud configuration is initialized - When sync entities fails with 404 """ - self._store.pop_agent_user_id(agent_user_id) + + @callback + @abstractmethod + def async_get_agent_users(self) -> Collection[str]: + """Return known agent users.""" @callback def async_enable_local_sdk(self) -> None: @@ -330,7 +328,7 @@ class AbstractConfig(ABC): self._local_sdk_active = False return - for user_agent_id in self._store.agent_user_ids: + for user_agent_id in self.async_get_agent_users(): if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break @@ -375,7 +373,7 @@ class AbstractConfig(ABC): if not self._local_sdk_active: return - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): webhook_id = self.get_local_webhook_id(agent_user_id) _LOGGER.debug( "Unregister webhook handler %s for agent user id %s", @@ -454,65 +452,6 @@ class AbstractConfig(ABC): return json_response(result) -class GoogleConfigStore: - """A configuration store for google assistant.""" - - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN - - def __init__(self, hass): - """Initialize a configuration store.""" - self._hass = hass - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) - self._data = None - - async def async_initialize(self): - """Finish initializing the ConfigStore.""" - should_save_data = False - if (data := await self._store.async_load()) is None: - # if the store is not found create an empty one - # Note that the first request is always a cloud request, - # and that will store the correct agent user id to be used for local requests - data = { - STORE_AGENT_USER_IDS: {}, - } - should_save_data = True - - for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): - if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: - data[STORE_AGENT_USER_IDS][agent_user_id] = { - **agent_user_data, - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - should_save_data = True - - if should_save_data: - await self._store.async_save(data) - - self._data = data - - @property - def agent_user_ids(self): - """Return a list of connected agent user_ids.""" - return self._data[STORE_AGENT_USER_IDS] - - @callback - def add_agent_user_id(self, agent_user_id): - """Add an agent user id to store.""" - if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS][agent_user_id] = { - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - self._store.async_delay_save(lambda: self._data, 1.0) - - @callback - def pop_agent_user_id(self, agent_user_id): - """Remove agent user id from store.""" - if agent_user_id in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) - self._store.async_delay_save(lambda: self._data, 1.0) - - class RequestData: """Hold data associated with a particular request.""" diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 9207f917458..0eaed0ca48a 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -11,14 +11,15 @@ from aiohttp import ClientError, ClientResponseError from aiohttp.web import Request, Response import jwt +from homeassistant.components import webhook from homeassistant.components.http import HomeAssistantView from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES - -# Typing imports -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import dt as dt_util +from homeassistant.helpers.storage import STORAGE_DIR, Store +from homeassistant.util import dt as dt_util, json as json_util from .const import ( CONF_CLIENT_EMAIL, @@ -30,12 +31,14 @@ from .const import ( CONF_REPORT_STATE, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DOMAIN, GOOGLE_ASSISTANT_API_ENDPOINT, HOMEGRAPH_SCOPE, HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, REQUEST_SYNC_BASE_URL, SOURCE_CLOUD, + STORE_AGENT_USER_IDS, STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from .helpers import AbstractConfig @@ -78,6 +81,8 @@ async def _get_homegraph_token( class GoogleConfig(AbstractConfig): """Config for manual setup of Google.""" + _store: GoogleConfigStore + def __init__(self, hass, config): """Initialize the config.""" super().__init__(hass) @@ -87,6 +92,10 @@ class GoogleConfig(AbstractConfig): async def async_initialize(self): """Perform async initialization of config.""" + # We need to initialize the store before calling super + self._store = GoogleConfigStore(self.hass) + await self._store.async_initialize() + await super().async_initialize() self.async_enable_local_sdk() @@ -191,6 +200,28 @@ class GoogleConfig(AbstractConfig): _LOGGER.error("No configuration for request_sync available") return HTTPStatus.INTERNAL_SERVER_ERROR + async def async_connect_agent_user(self, agent_user_id: str): + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + self._store.add_agent_user_id(agent_user_id) + + async def async_disconnect_agent_user(self, agent_user_id: str): + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + self._store.pop_agent_user_id(agent_user_id) + + @callback + def async_get_agent_users(self): + """Return known agent users.""" + return self._store.agent_user_ids + async def _async_update_token(self, force=False): if CONF_SERVICE_ACCOUNT not in self._config: _LOGGER.error("Trying to get homegraph api token without service account") @@ -258,6 +289,71 @@ class GoogleConfig(AbstractConfig): return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) +class GoogleConfigStore: + """A configuration store for google assistant.""" + + _STORAGE_VERSION = 1 + _STORAGE_VERSION_MINOR = 2 + _STORAGE_KEY = DOMAIN + _data: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a configuration store.""" + self._hass = hass + self._store: Store[dict[str, Any]] = Store( + hass, + self._STORAGE_VERSION, + self._STORAGE_KEY, + minor_version=self._STORAGE_VERSION_MINOR, + ) + + async def async_initialize(self) -> None: + """Finish initializing the ConfigStore.""" + should_save_data = False + if (data := await self._store.async_load()) is None: + # if the store is not found create an empty one + # Note that the first request is always a cloud request, + # and that will store the correct agent user id to be used for local requests + data = { + STORE_AGENT_USER_IDS: {}, + } + should_save_data = True + + for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): + if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: + data[STORE_AGENT_USER_IDS][agent_user_id] = { + **agent_user_data, + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + should_save_data = True + + if should_save_data: + await self._store.async_save(data) + + self._data = data + + @property + def agent_user_ids(self) -> dict[str, Any]: + """Return a list of connected agent user_ids.""" + return self._data[STORE_AGENT_USER_IDS] + + @callback + def add_agent_user_id(self, agent_user_id: str) -> None: + """Add an agent user id to store.""" + if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS][agent_user_id] = { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + self._store.async_delay_save(lambda: self._data, 1.0) + + @callback + def pop_agent_user_id(self, agent_user_id: str) -> None: + """Remove agent user id from store.""" + if agent_user_id in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) + self._store.async_delay_save(lambda: self._data, 1.0) + + class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" @@ -280,3 +376,26 @@ class GoogleAssistantView(HomeAssistantView): SOURCE_CLOUD, ) return self.json(result) + + +async def async_get_users(hass: HomeAssistant) -> list[str]: + """Return stored users. + + This is called by the cloud integration to import from the previously shared store. + """ + # pylint: disable-next=protected-access + path = hass.config.path(STORAGE_DIR, GoogleConfigStore._STORAGE_KEY) + try: + store_data = await hass.async_add_executor_job(json_util.load_json, path) + except HomeAssistantError: + return [] + + if ( + not isinstance(store_data, dict) + or not (data := store_data.get("data")) + or not isinstance(data, dict) + or not (agent_user_ids := data.get("agent_user_ids")) + or not isinstance(agent_user_ids, dict) + ): + return [] + return list(agent_user_ids) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 7fb2d1aff3e..9f1e7a22dc4 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -42,7 +42,7 @@ def mock_conf(hass, cloud_prefs): GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, - Mock(claims={"cognito:username": "abcdefghjkl"}), + Mock(username="abcdefghjkl"), ) @@ -104,9 +104,11 @@ async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> Non assert await async_setup_component(hass, "homeassistant", {}) await mock_conf.async_initialize() + assert len(mock_conf.async_get_agent_users()) == 0 + await mock_conf.async_connect_agent_user("mock-user-id") - assert len(mock_conf._store.agent_user_ids) == 1 + assert len(mock_conf.async_get_agent_users()) == 1 with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", @@ -115,7 +117,7 @@ async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> Non assert ( await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND ) - assert len(mock_conf._store.agent_user_ids) == 0 + assert len(mock_conf.async_get_agent_users()) == 0 assert len(mock_request_sync.mock_calls) == 1 @@ -144,7 +146,7 @@ async def test_google_update_expose_trigger_sync( GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, - Mock(claims={"cognito:username": "abcdefghjkl"}), + Mock(username="abcdefghjkl"), ) await config.async_initialize() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -270,7 +272,8 @@ async def test_google_device_registry_sync( with patch.object(config, "async_sync_entities_all"): await config.async_initialize() await hass.async_block_till_done() - await config.async_connect_agent_user("mock-user-id") + await config.async_connect_agent_user("mock-user-id") + await hass.async_block_till_done() with patch.object(config, "async_schedule_google_sync_all") as mock_sync: # Device registry updated with non-relevant changes @@ -326,7 +329,6 @@ async def test_sync_google_when_started( ) with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() - await config.async_connect_agent_user("mock-user-id") await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 @@ -341,7 +343,6 @@ async def test_sync_google_on_home_assistant_start( hass.set_state(CoreState.starting) with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() - await config.async_connect_agent_user("mock-user-id") assert len(mock_sync.mock_calls) == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 6dc1ca09122..608f6ef3df0 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -2,6 +2,8 @@ from typing import Any from unittest.mock import patch +import pytest + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.cloud.prefs import STORAGE_KEY, CloudPreferences from homeassistant.core import HomeAssistant @@ -98,3 +100,25 @@ async def test_setup_remove_cloud_user( assert cloud_user2 assert cloud_user2.groups[0].id == GROUP_ID_ADMIN assert cloud_user2.id != cloud_user.id + + +@pytest.mark.parametrize( + ("google_assistant_users", "google_connected"), + [([], False), (["cloud-user"], True), (["other-user"], False)], +) +async def test_import_google_assistant_settings( + hass: HomeAssistant, + hass_storage: dict[str, Any], + google_assistant_users: list[str], + google_connected: bool, +) -> None: + """Test importing from the google assistant store.""" + hass_storage[STORAGE_KEY] = {"version": 1, "data": {"username": "cloud-user"}} + + with patch( + "homeassistant.components.cloud.prefs.async_get_google_assistant_users" + ) as mock_get_users: + mock_get_users.return_value = google_assistant_users + prefs = CloudPreferences(hass) + await prefs.async_initialize() + assert prefs.google_connected == google_connected diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index b7d329575c9..6ec35c68ee5 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -6,7 +6,7 @@ from homeassistant.components.google_assistant import helpers, http def mock_google_config_store(agent_user_ids=None): """Fake a storage for google assistant.""" - store = MagicMock(spec=helpers.GoogleConfigStore) + store = MagicMock(spec=http.GoogleConfigStore) if agent_user_ids is not None: store.agent_user_ids = agent_user_ids else: diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index aaa3949caaf..1de1799358f 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -1,7 +1,6 @@ """Test Google Assistant helpers.""" from datetime import timedelta from http import HTTPStatus -from typing import Any from unittest.mock import Mock, call, patch import pytest @@ -23,12 +22,7 @@ from homeassistant.util import dt as dt_util from . import MockConfig -from tests.common import ( - MockConfigEntry, - async_capture_events, - async_fire_time_changed, - async_mock_service, -) +from tests.common import MockConfigEntry, async_capture_events, async_mock_service from tests.typing import ClientSessionGenerator @@ -274,72 +268,6 @@ async def test_config_local_sdk_if_ssl_enabled( assert await resp.read() == b"" -async def test_agent_user_id_storage( - hass: HomeAssistant, hass_storage: dict[str, Any] -) -> None: - """Test a disconnect message.""" - - hass_storage["google_assistant"] = { - "version": 1, - "minor_version": 1, - "key": "google_assistant", - "data": { - "agent_user_ids": { - "agent_1": { - "local_webhook_id": "test_webhook", - } - }, - }, - } - - store = helpers.GoogleConfigStore(hass) - await store.async_initialize() - - assert hass_storage["google_assistant"] == { - "version": 1, - "minor_version": 1, - "key": "google_assistant", - "data": { - "agent_user_ids": { - "agent_1": { - "local_webhook_id": "test_webhook", - } - }, - }, - } - - async def _check_after_delay(data): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) - await hass.async_block_till_done() - - assert ( - list(hass_storage["google_assistant"]["data"]["agent_user_ids"].keys()) - == data - ) - - store.add_agent_user_id("agent_2") - await _check_after_delay(["agent_1", "agent_2"]) - - store.pop_agent_user_id("agent_1") - await _check_after_delay(["agent_2"]) - - hass_storage["google_assistant"] = { - "version": 1, - "minor_version": 1, - "key": "google_assistant", - "data": { - "agent_user_ids": {"agent_1": {}}, - }, - } - store = helpers.GoogleConfigStore(hass) - await store.async_initialize() - - assert ( - STORE_GOOGLE_LOCAL_WEBHOOK_ID - in hass_storage["google_assistant"]["data"]["agent_user_ids"]["agent_1"] - ) - - async def test_agent_user_id_connect() -> None: """Test the connection and disconnection of users.""" config = MockConfig() diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index b3ec486e818..6f2d61d03ae 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -1,10 +1,13 @@ """Test Google http services.""" from datetime import UTC, datetime, timedelta from http import HTTPStatus +import json +import os from typing import Any from unittest.mock import ANY, patch from uuid import uuid4 +import py import pytest from homeassistant.components.google_assistant import GOOGLE_ASSISTANT_SCHEMA @@ -18,14 +21,22 @@ from homeassistant.components.google_assistant.const import ( ) from homeassistant.components.google_assistant.http import ( GoogleConfig, + GoogleConfigStore, _get_homegraph_jwt, _get_homegraph_token, + async_get_users, ) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import async_capture_events, async_mock_service +from tests.common import ( + async_capture_events, + async_fire_time_changed, + async_mock_service, + async_test_home_assistant, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -469,3 +480,177 @@ async def test_async_enable_local_sdk( "Cannot process request for webhook **REDACTED** as no linked agent user is found:" in caplog.text ) + + +async def test_agent_user_id_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test a disconnect message.""" + + hass_storage["google_assistant"] = { + "version": 1, + "minor_version": 1, + "key": "google_assistant", + "data": { + "agent_user_ids": { + "agent_1": { + "local_webhook_id": "test_webhook", + } + }, + }, + } + + store = GoogleConfigStore(hass) + await store.async_initialize() + + assert hass_storage["google_assistant"] == { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": { + "agent_user_ids": { + "agent_1": { + "local_webhook_id": "test_webhook", + } + }, + }, + } + + async def _check_after_delay(data): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + + assert ( + list(hass_storage["google_assistant"]["data"]["agent_user_ids"].keys()) + == data + ) + + store.add_agent_user_id("agent_2") + await _check_after_delay(["agent_1", "agent_2"]) + + store.pop_agent_user_id("agent_1") + await _check_after_delay(["agent_2"]) + + hass_storage["google_assistant"] = { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": { + "agent_user_ids": {"agent_1": {}}, + }, + } + store = GoogleConfigStore(hass) + await store.async_initialize() + + assert ( + STORE_GOOGLE_LOCAL_WEBHOOK_ID + in hass_storage["google_assistant"]["data"]["agent_user_ids"]["agent_1"] + ) + + +async def test_async_get_users_no_store(hass: HomeAssistant) -> None: + """Test async_get_users when there is no store.""" + assert await async_get_users(hass) == [] + + +async def test_async_get_users_from_store(tmpdir: py.path.local) -> None: + """Test async_get_users from a store. + + This test ensures we can load from data saved by GoogleConfigStore. + """ + async with async_test_home_assistant() as hass: + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "temp_storage" + ) + + store = GoogleConfigStore(hass) + await store.async_initialize() + + store.add_agent_user_id("agent_1") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + + assert await async_get_users(hass) == ["agent_1"] + + +VALID_STORE_DATA = json.dumps( + { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": { + "agent_user_ids": {"agent_1": {}}, + }, + } +) + + +NO_DATA = json.dumps( + { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + } +) + + +DATA_NOT_DICT = json.dumps( + { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": "hello", + } +) + + +NO_AGENT_USER_IDS = json.dumps( + { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": {}, + } +) + + +AGENT_USER_IDS_NOT_DICT = json.dumps( + { + "version": 1, + "minor_version": 2, + "key": "google_assistant", + "data": { + "agent_user_ids": "hello", + }, + } +) + + +@pytest.mark.parametrize( + ("store_data", "expected_users"), + [ + (VALID_STORE_DATA, ["agent_1"]), + ("", []), + ("not_a_dict", []), + (NO_DATA, []), + (DATA_NOT_DICT, []), + (NO_AGENT_USER_IDS, []), + (AGENT_USER_IDS_NOT_DICT, []), + ], +) +async def test_async_get_users( + tmpdir: py.path.local, store_data: str, expected_users: list[str] +) -> None: + """Test async_get_users from stored JSON data.""" + async with async_test_home_assistant() as hass: + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "temp_storage" + ) + path = hass.config.config_dir / ".storage" / GoogleConfigStore._STORAGE_KEY + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(store_data) + + assert await async_get_users(hass) == expected_users + + await hass.async_stop()