From b014d558ff6ad20cef7301e6b1c11116f3fa89ea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Jun 2022 07:15:53 -0700 Subject: [PATCH] Add application credentials platform for nest and deprecate yaml for SDM API (#73050) * Update the nest integration to be useable fully from the config flow * Support discovery in nest config flow * Remove configuration entries * Remove unused import * Remove dead code * Update homeassistant/components/nest/strings.json Co-authored-by: Martin Hjelmare * Remove commented out code * Use config flow for app auth reauthentication path * Improves for re-auth for upgrading existing project and creds * More dead code removal * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove outdated code * Update homeassistant/components/nest/config_flow.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/nest/__init__.py | 81 ++- homeassistant/components/nest/api.py | 21 +- .../nest/application_credentials.py | 24 + homeassistant/components/nest/auth.py | 54 -- homeassistant/components/nest/config_flow.py | 341 +++++---- homeassistant/components/nest/const.py | 2 +- homeassistant/components/nest/manifest.json | 2 +- homeassistant/components/nest/strings.json | 38 +- .../components/nest/translations/en.json | 35 +- .../generated/application_credentials.py | 1 + .../helpers/config_entry_oauth2_flow.py | 9 +- tests/components/nest/common.py | 34 +- tests/components/nest/conftest.py | 32 +- tests/components/nest/test_api.py | 5 + .../nest/test_config_flow_legacy.py | 9 - tests/components/nest/test_config_flow_sdm.py | 658 +++++++++++------- tests/components/nest/test_diagnostics.py | 2 - tests/components/nest/test_events.py | 4 +- tests/components/nest/test_init_sdm.py | 21 +- 19 files changed, 856 insertions(+), 517 deletions(-) create mode 100644 homeassistant/components/nest/application_credentials.py delete mode 100644 homeassistant/components/nest/auth.py diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 0920b37e6ef..29c2d817acd 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,6 +22,10 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.camera import Image, img_util from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -54,11 +58,14 @@ from . import api, config_flow from .const import ( CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, + CONF_SUBSCRIBER_ID_IMPORTED, DATA_DEVICE_MANAGER, DATA_NEST_CONFIG, DATA_SDM, DATA_SUBSCRIBER, DOMAIN, + INSTALLED_AUTH_DOMAIN, + WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -112,20 +119,22 @@ THUMBNAIL_SIZE_PX = 175 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_NEST_CONFIG] = config.get(DOMAIN) + + hass.http.register_view(NestEventMediaView(hass)) + hass.http.register_view(NestEventMediaThumbnailView(hass)) if DOMAIN not in config: - return True + return True # ConfigMode.SDM_APPLICATION_CREDENTIALS + + # Note that configuration.yaml deprecation warnings are handled in the + # config entry since we don't know what type of credentials we have and + # whether or not they can be imported. + hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy(hass, config) - config_flow.register_flow_implementation_from_config(hass, config) - - hass.http.register_view(NestEventMediaView(hass)) - hass.http.register_view(NestEventMediaThumbnailView(hass)) - return True @@ -171,9 +180,13 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" - if DATA_SDM not in entry.data: + config_mode = config_flow.get_config_mode(hass) + if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) + if config_mode == config_flow.ConfigMode.SDM: + await async_import_config(hass, entry) + subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -223,6 +236,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Attempt to import configuration.yaml settings.""" + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + new_data = { + CONF_PROJECT_ID: config[CONF_PROJECT_ID], + **entry.data, + } + if CONF_SUBSCRIBER_ID not in entry.data: + if CONF_SUBSCRIBER_ID not in config: + raise ValueError("Configuration option 'subscriber_id' missing") + new_data.update( + { + CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], + CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber + } + ) + hass.config_entries.async_update_entry(entry, data=new_data) + + if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # App Auth credentials have been deprecated and must be re-created + # by the user in the config flow + raise ConfigEntryAuthFailed( + "Google has deprecated App Auth credentials, and the integration " + "must be reconfigured in the UI to restore access to Nest Devices." + ) + + if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + ), + WEB_AUTH_DOMAIN, + ) + + _LOGGER.warning( + "Configuration of Nest integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "(including OAuth Application Credentials) has been imported into " + "the UI automatically and can be safely removed from your " + "configuration.yaml file" + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: @@ -242,7 +301,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle removal of pubsub subscriptions created during config flow.""" - if DATA_SDM not in entry.data or CONF_SUBSCRIBER_ID not in entry.data: + if ( + DATA_SDM not in entry.data + or CONF_SUBSCRIBER_ID not in entry.data + or CONF_SUBSCRIBER_ID_IMPORTED in entry.data + ): return subscriber = await api.new_subscriber(hass, entry) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 830db926d9a..4d92cc30b1a 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -12,7 +12,6 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -20,8 +19,6 @@ from .const import ( API_URL, CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, - DATA_NEST_CONFIG, - DOMAIN, OAUTH2_TOKEN, SDM_SCOPES, ) @@ -111,21 +108,19 @@ async def new_subscriber( hass, entry ) ) - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - if not ( - subscriber_id := entry.data.get( - CONF_SUBSCRIBER_ID, config.get(CONF_SUBSCRIBER_ID) - ) + if not isinstance( + implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): - _LOGGER.error("Configuration option 'subscriber_id' required") - return None + raise ValueError(f"Unexpected auth implementation {implementation}") + if not (subscriber_id := entry.data.get(CONF_SUBSCRIBER_ID)): + raise ValueError("Configuration option 'subscriber_id' missing") auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], + implementation.client_id, + implementation.client_secret, ) - return GoogleNestSubscriber(auth, config[CONF_PROJECT_ID], subscriber_id) + return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscriber_id) def new_subscriber_with_token( diff --git a/homeassistant/components/nest/application_credentials.py b/homeassistant/components/nest/application_credentials.py new file mode 100644 index 00000000000..7d88bc37322 --- /dev/null +++ b/homeassistant/components/nest/application_credentials.py @@ -0,0 +1,24 @@ +"""application_credentials platform for nest.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url="", # Overridden in config flow as needs device access project id + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/nest/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/nest/auth.py b/homeassistant/components/nest/auth.py deleted file mode 100644 index 648623b64c7..00000000000 --- a/homeassistant/components/nest/auth.py +++ /dev/null @@ -1,54 +0,0 @@ -"""OAuth implementations.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import ( - INSTALLED_AUTH_DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - OOB_REDIRECT_URI, - WEB_AUTH_DOMAIN, -) - - -class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for web applications.""" - - name = "OAuth for Web" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize WebAuth.""" - super().__init__( - hass, - WEB_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - -class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for installed applications.""" - - name = "OAuth for Apps" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize InstalledAppAuth.""" - super().__init__( - hass, - INSTALLED_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - @property - def redirect_uri(self) -> str: - """Return the redirect uri.""" - return OOB_REDIRECT_URI diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 61a61f6c8e0..bacd61447f5 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,27 +1,11 @@ """Config flow to configure Nest. This configuration flow supports the following: - - SDM API with Installed app flow where user enters an auth code manually - SDM API with Web OAuth flow with redirect back to Home Assistant - Legacy Nest API auth flow with where user enters an auth code manually NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with -some overrides to support installed app and old APIs auth flow, reauth, -and other custom steps inserted in the middle of the flow. - -The notable config flow steps are: -- user: To dispatch between API versions -- auth: Inserted to add a hook for the installed app flow to accept a token -- async_oauth_create_entry: Overridden to handle when OAuth is complete. This - does not actually create the entry, but holds on to the OAuth token data - for later -- pubsub: Configure the pubsub subscription. Note that subscriptions created - by the config flow are deleted when removed. -- finish: Handles creating a new configuration entry or updating the existing - configuration entry for reauth. - -The SDM API config flow supports a hybrid of configuration.yaml (used as defaults) -and config flow. +some overrides to custom steps inserted in the middle of the flow. """ from __future__ import annotations @@ -43,16 +27,15 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.typing import ConfigType from homeassistant.util import get_random_string from homeassistant.util.json import load_json -from . import api, auth +from . import api from .const import ( CONF_CLOUD_PROJECT_ID, CONF_PROJECT_ID, @@ -60,14 +43,36 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - OOB_REDIRECT_URI, + INSTALLED_AUTH_DOMAIN, + OAUTH2_AUTHORIZE, SDM_SCOPES, ) DATA_FLOW_IMPL = "nest_flow_implementation" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" SUBSCRIPTION_RAND_LENGTH = 10 + +MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" + +# URLs for Configure Cloud Project step CLOUD_CONSOLE_URL = "https://console.cloud.google.com/home/dashboard" +SDM_API_URL = ( + "https://console.cloud.google.com/apis/library/smartdevicemanagement.googleapis.com" +) +PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapis.com" + +# URLs for Configure Device Access Project step +DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" + +# URLs for App Auth deprecation and upgrade +UPGRADE_MORE_INFO_URL = ( + "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" +) +DEVICE_ACCESS_CONSOLE_EDIT_URL = ( + "https://console.nest.google.com/device-access/project/{project_id}/information" +) + + _LOGGER = logging.getLogger(__name__) @@ -76,13 +81,15 @@ class ConfigMode(Enum): SDM = 1 # SDM api with configuration.yaml LEGACY = 2 # "Works with Nest" API + SDM_APPLICATION_CREDENTIALS = 3 # Config entry only def get_config_mode(hass: HomeAssistant) -> ConfigMode: """Return the integration configuration mode.""" - if DOMAIN not in hass.data: - return ConfigMode.SDM - config = hass.data[DOMAIN][DATA_NEST_CONFIG] + if DOMAIN not in hass.data or not ( + config := hass.data[DOMAIN].get(DATA_NEST_CONFIG) + ): + return ConfigMode.SDM_APPLICATION_CREDENTIALS if CONF_PROJECT_ID in config: return ConfigMode.SDM return ConfigMode.LEGACY @@ -120,31 +127,6 @@ def register_flow_implementation( } -def register_flow_implementation_from_config( - hass: HomeAssistant, - config: ConfigType, -) -> None: - """Register auth implementations for SDM API from configuration yaml.""" - NestFlowHandler.async_register_implementation( - hass, - auth.InstalledAppAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - NestFlowHandler.async_register_implementation( - hass, - auth.WebAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - - class NestAuthError(HomeAssistantError): """Base class for Nest auth errors.""" @@ -179,7 +161,7 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._reauth = False + self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -189,6 +171,21 @@ class NestFlowHandler( """Return the configuration type for this flow.""" return get_config_mode(self.hass) + def _async_reauth_entry(self) -> ConfigEntry | None: + """Return existing entry for reauth.""" + if self.source != SOURCE_REAUTH or not ( + entry_id := self.context.get("entry_id") + ): + return None + return next( + ( + entry + for entry in self._async_current_entries() + if entry.entry_id == entry_id + ), + None, + ) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -204,11 +201,19 @@ class NestFlowHandler( "prompt": "consent", } + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize based on user input.""" + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = self._data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID, "")) + query = await super().async_generate_authorize_url() + authorize_url = OAUTH2_AUTHORIZE.format(project_id=project_id) + return f"{authorize_url}{query}" + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Complete OAuth setup and finish pubsub or finish.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(data) - if not self._configure_pubsub(): + if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self.async_step_finish() return await self.async_step_pubsub() @@ -221,8 +226,8 @@ class NestFlowHandler( if user_input is None: _LOGGER.error("Reauth invoked with empty config entry data") return self.async_abort(reason="missing_configuration") - self._reauth = True self._data.update(user_input) + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -232,87 +237,178 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - existing_entries = self._async_current_entries() - if existing_entries: - # Pick an existing auth implementation for Reauth if present. Note - # only one ConfigEntry is allowed so its safe to pick the first. - entry = next(iter(existing_entries)) - if "auth_implementation" in entry.data: - data = {"implementation": entry.data["auth_implementation"]} - return await self.async_step_user(data) + if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # The config entry points to an auth mechanism that no longer works and the + # user needs to take action in the google cloud console to resolve. First + # prompt to create app creds, then later ensure they've updated the device + # access console. + self._upgrade = True + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if not implementations: + return await self.async_step_auth_upgrade() return await self.async_step_user() + async def async_step_auth_upgrade( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Give instructions for upgrade of deprecated app auth.""" + assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" + if user_input is None: + return self.async_show_form( + step_id="auth_upgrade", + description_placeholders={ + "more_info_url": UPGRADE_MORE_INFO_URL, + }, + ) + # Abort this flow and ask the user for application credentials. The frontend + # will restart a new config flow after the user finishes so schedule a new + # re-auth config flow for the same entry so the user may resume. + if reauth_entry := self._async_reauth_entry(): + self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) + return self.async_abort(reason="missing_credentials") + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self.config_mode == ConfigMode.SDM: - # Reauth will update an existing entry - if self._async_current_entries() and not self._reauth: - return self.async_abort(reason="single_instance_allowed") + if self.config_mode == ConfigMode.LEGACY: + return await self.async_step_init(user_input) + self._data[DATA_SDM] = {} + # Reauth will update an existing entry + entries = self._async_current_entries() + if entries and self.source != SOURCE_REAUTH: + return self.async_abort(reason="single_instance_allowed") + if self.source == SOURCE_REAUTH: return await super().async_step_user(user_input) - return await self.async_step_init(user_input) + # Application Credentials setup needs information from the user + # before creating the OAuth URL + return await self.async_step_create_cloud_project() + + async def async_step_create_cloud_project( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle initial step in app credentails flow.""" + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if implementations: + return await self.async_step_cloud_project() + # This informational step explains to the user how to setup the + # cloud console and other pre-requisites needed before setting up + # an application credential. This extra step also allows discovery + # to start the config flow rather than aborting. The abort step will + # redirect the user to the right panel in the UI then return with a + # valid auth implementation. + if user_input is not None: + return self.async_abort(reason="missing_credentials") + return self.async_show_form( + step_id="create_cloud_project", + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "sdm_api_url": SDM_API_URL, + "pubsub_api_url": PUBSUB_API_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_cloud_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle cloud project in user input.""" + if user_input is not None: + self._data.update(user_input) + return await self.async_step_device_project() + return self.async_show_form( + step_id="cloud_project", + data_schema=vol.Schema( + { + vol.Required(CONF_CLOUD_PROJECT_ID): str, + } + ), + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_device_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Collect device access project from user input.""" + errors = {} + if user_input is not None: + if user_input[CONF_PROJECT_ID] == self._data[CONF_CLOUD_PROJECT_ID]: + _LOGGER.error( + "Device Access Project ID and Cloud Project ID must not be the same, see documentation" + ) + errors[CONF_PROJECT_ID] = "wrong_project_id" + else: + self._data.update(user_input) + return await super().async_step_user() + + return self.async_show_form( + step_id="device_project", + data_schema=vol.Schema( + { + vol.Required(CONF_PROJECT_ID): str, + } + ), + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + errors=errors, + ) async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Create an entry for auth.""" - if self.flow_impl.domain == "nest.installed": - # The default behavior from the parent class is to redirect the - # user with an external step. When using installed app auth, we - # instead prompt the user to sign in and copy/paste and - # authentication code back into this form. - # Note: This is similar to the Legacy API flow below, but it is - # simpler to reuse the OAuth logic in the parent class than to - # reuse SDM code with Legacy API code. - if user_input is not None: - self.external_data = { - "code": user_input["code"], - "state": {"redirect_uri": OOB_REDIRECT_URI}, - } - return await super().async_step_creation(user_input) - - result = await super().async_step_auth() - return self.async_show_form( - step_id="auth", - description_placeholders={"url": result["url"]}, - data_schema=vol.Schema({vol.Required("code"): str}), - ) + """Verify any last pre-requisites before sending user through OAuth flow.""" + if user_input is None and self._upgrade: + # During app auth upgrade we need the user to update their device access project + # before we redirect to the authentication flow. + return await self.async_step_device_project_upgrade() return await super().async_step_auth(user_input) - def _configure_pubsub(self) -> bool: - """Return True if the config flow should configure Pub/Sub.""" - if self._reauth: - # Just refreshing tokens and preserving existing subscriber id - return False - if CONF_SUBSCRIBER_ID in self.hass.data[DOMAIN][DATA_NEST_CONFIG]: - # Hard coded configuration.yaml skips pubsub in config flow - return False - # No existing subscription configured, so create in config flow - return True + async def async_step_device_project_upgrade( + self, user_input: dict | None = None + ) -> FlowResult: + """Update the device access project.""" + if user_input is not None: + # Resume OAuth2 redirects + return await super().async_step_auth() + if not isinstance( + self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation + ): + raise ValueError(f"Unexpected OAuth implementation: {self.flow_impl}") + client_id = self.flow_impl.client_id + return self.async_show_form( + step_id="device_project_upgrade", + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "more_info_url": UPGRADE_MORE_INFO_URL, + "client_id": client_id, + }, + ) async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure and create Pub/Sub subscriber.""" - # Populate data from the previous config entry during reauth, then - # overwrite with the user entered values. - data = {} - if self._reauth: - data.update(self._data) - if user_input: - data.update(user_input) + data = { + **self._data, + **(user_input if user_input is not None else {}), + } cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID)) - errors = {} - config = self.hass.data[DOMAIN][DATA_NEST_CONFIG] - if cloud_project_id == config[CONF_PROJECT_ID]: - _LOGGER.error( - "Wrong Project ID. Device Access Project ID used, but expected Cloud Project ID" - ) - errors[CONF_CLOUD_PROJECT_ID] = "wrong_project_id" - - if user_input is not None and not errors: + errors: dict[str, str] = {} + if cloud_project_id: # Create the subscriber id and/or verify it already exists. Note that # the existing id is used, and create call below is idempotent if not (subscriber_id := data.get(CONF_SUBSCRIBER_ID, "")): @@ -321,7 +417,7 @@ class NestFlowHandler( subscriber = api.new_subscriber_with_token( self.hass, self._data["token"]["access_token"], - config[CONF_PROJECT_ID], + project_id, subscriber_id, ) try: @@ -373,18 +469,11 @@ class NestFlowHandler( # Update existing config entry when in the reauth flow. This # integration only supports one config entry so remove any prior entries # added before the "single_instance_allowed" check was added - existing_entries = self._async_current_entries() - if existing_entries: - updated = False - for entry in existing_entries: - if updated: - await self.hass.config_entries.async_remove(entry.entry_id) - continue - updated = True - self.hass.config_entries.async_update_entry( - entry, data=self._data, unique_id=DOMAIN - ) - await self.hass.config_entries.async_reload(entry.entry_id) + if entry := self._async_reauth_entry(): + self.hass.config_entries.async_update_entry( + entry, data=self._data, unique_id=DOMAIN + ) + await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") title = self.flow_impl.name if self._structure_config_title: diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index bd951756eae..64c27c1643b 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -11,6 +11,7 @@ INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" CONF_PROJECT_ID = "project_id" CONF_SUBSCRIBER_ID = "subscriber_id" +CONF_SUBSCRIBER_ID_IMPORTED = "subscriber_id_imported" CONF_CLOUD_PROJECT_ID = "cloud_project_id" SIGNAL_NEST_UPDATE = "nest_update" @@ -25,4 +26,3 @@ SDM_SCOPES = [ "https://www.googleapis.com/auth/pubsub", ] API_URL = "https://smartdevicemanagement.googleapis.com/v1" -OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 4f768e08843..d0588d46f06 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,7 +2,7 @@ "domain": "nest", "name": "Nest", "config_flow": true, - "dependencies": ["ffmpeg", "http", "auth"], + "dependencies": ["ffmpeg", "http", "application_credentials"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.0.0"], diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1d3dfda1708..212903179b7 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -1,16 +1,38 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "step": { + "auth_upgrade": { + "title": "Nest: App Auth Deprecation", + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." + }, + "device_project_upgrade": { + "title": "Nest: Update Device Access Project", + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" + }, + "create_cloud_project": { + "title": "Nest: Create and configure Cloud Project", + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." + }, + "cloud_project": { + "title": "Nest: Enter Cloud Project ID", + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "data": { + "cloud_project_id": "Google Cloud Project ID" + } + }, + "device_project": { + "title": "Nest: Create a Device Access Project", + "description": "Create a Nest Device Access project which **requires a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "data": { + "project_id": "Device Access Project ID" + } + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account", - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "data": { - "code": "[%key:common::config_flow::data::access_token%]" - } - }, "pubsub": { "title": "Configure Google Cloud", "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", @@ -43,7 +65,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)", + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)", "subscriber_error": "Unknown subscriber error, see logs" }, "abort": { diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 6376807302b..90f7c244f7b 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "abort": { "authorize_url_timeout": "Timeout generating authorize URL.", @@ -19,15 +22,41 @@ "subscriber_error": "Unknown subscriber error, see logs", "timeout": "Timeout validating code", "unknown": "Unexpected error", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)" + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)" }, "step": { "auth": { "data": { "code": "Access Token" }, - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "title": "Link Google Account" + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below ([more info]({more_info_url})).", + "title": "Nest: Link Google Account" + }, + "auth_upgrade": { + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices.", + "title": "Nest: App Auth Deprecation" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "title": "Nest: Enter Cloud Project ID" + }, + "create_cloud_project": { + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up.", + "title": "Nest: Create and configure Cloud Project" + }, + "device_project": { + "data": { + "project_id": "Device Access Project ID" + }, + "description": "Create a Nest Device Access project which **requires a US$5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "title": "Nest: Create a Device Access Project" + }, + "device_project_upgrade": { + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`", + "title": "Nest: Update Device Access Project" }, "init": { "data": { diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6d40b3fdef7..ba9762f58c0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lyric", "neato", + "nest", "netatmo", "senz", "spotify", diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 9322d6e9dc1..0dc3415f7a9 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -233,6 +233,11 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Extra data that needs to be appended to the authorize url.""" return {} + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize.""" + url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + return str(URL(url).update_query(self.extra_authorize_data)) + async def async_step_pick_implementation( self, user_input: dict | None = None ) -> FlowResult: @@ -278,7 +283,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): try: async with async_timeout.timeout(10): - url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + url = await self.async_generate_authorize_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: @@ -289,8 +294,6 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): }, ) - url = str(URL(url).update_query(self.extra_authorize_data)) - return self.async_external_step(step_id="auth", url=url) async def async_step_creation( diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 988906606ad..765a954b6de 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -1,8 +1,10 @@ """Common libraries for test setup.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import copy -from dataclasses import dataclass +from dataclasses import dataclass, field import time from typing import Any, Generator, TypeVar @@ -13,6 +15,7 @@ from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import CachePolicy from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import SDM_SCOPES @@ -73,8 +76,10 @@ def create_config_entry(token_expiration_time=None) -> MockConfigEntry: class NestTestConfig: """Holder for integration configuration.""" - config: dict[str, Any] - config_entry_data: dict[str, Any] + config: dict[str, Any] = field(default_factory=dict) + config_entry_data: dict[str, Any] | None = None + auth_implementation: str = WEB_AUTH_DOMAIN + credential: ClientCredential | None = None # Exercises mode where all configuration is in configuration.yaml @@ -86,7 +91,7 @@ TEST_CONFIG_YAML_ONLY = NestTestConfig( }, ) TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( - config=TEST_CONFIG_YAML_ONLY.config, config_entry_data=None + config=TEST_CONFIG_YAML_ONLY.config, ) # Exercises mode where subscriber id is created in the config flow, but @@ -106,8 +111,24 @@ TEST_CONFIG_HYBRID = NestTestConfig( "subscriber_id": SUBSCRIBER_ID, }, ) -TEST_CONFIGFLOW_HYBRID = NestTestConfig( - TEST_CONFIG_HYBRID.config, config_entry_data=None +TEST_CONFIGFLOW_HYBRID = NestTestConfig(TEST_CONFIG_HYBRID.config) + +# Exercises mode where all configuration is from the config flow +TEST_CONFIG_APP_CREDS = NestTestConfig( + config_entry_data={ + "sdm": {}, + "token": create_token_entry(), + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscriber_id": SUBSCRIBER_ID, + }, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) +TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( + config=TEST_CONFIG_APP_CREDS.config, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) TEST_CONFIG_LEGACY = NestTestConfig( @@ -126,6 +147,7 @@ TEST_CONFIG_LEGACY = NestTestConfig( }, }, }, + credential=None, ) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index fafd04c3764..bacb3924bcd 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -14,6 +14,9 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device_manager import DeviceManager import pytest +from homeassistant.components.application_credentials import ( + async_import_client_credential, +) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID from homeassistant.core import HomeAssistant @@ -22,9 +25,8 @@ from homeassistant.setup import async_setup_component from .common import ( DEVICE_ID, SUBSCRIBER_ID, - TEST_CONFIG_HYBRID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_YAML_ONLY, - WEB_AUTH_DOMAIN, CreateDevice, FakeSubscriber, NestTestConfig, @@ -183,14 +185,14 @@ def subscriber_id() -> str: @pytest.fixture -def auth_implementation() -> str | None: +def auth_implementation(nest_test_config: NestTestConfig) -> str | None: """Fixture to let tests override the auth implementation in the config entry.""" - return WEB_AUTH_DOMAIN + return nest_test_config.auth_implementation @pytest.fixture( - params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_HYBRID], - ids=["yaml-config-only", "hybrid-config"], + params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_APP_CREDS], + ids=["yaml-config-only", "app-creds"], ) def nest_test_config(request) -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" @@ -230,6 +232,20 @@ def config_entry( return MockConfigEntry(domain=DOMAIN, data=data) +@pytest.fixture(autouse=True) +async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> None: + """Fixture that provides the ClientCredential for the test if any.""" + if not nest_test_config.credential: + return + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + nest_test_config.credential, + nest_test_config.auth_implementation, + ) + + @pytest.fixture async def setup_base_platform( hass: HomeAssistant, @@ -240,9 +256,7 @@ async def setup_base_platform( """Fixture to setup the integration platform.""" if config_entry: config_entry.add_to_hass(hass) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", platforms): + with patch("homeassistant.components.nest.PLATFORMS", platforms): async def _setup_func() -> bool: assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 894fda09a8f..7d88ba1d329 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -11,6 +11,8 @@ The tests below exercise both cases during integration setup. import time from unittest.mock import patch +import pytest + from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.setup import async_setup_component @@ -23,6 +25,7 @@ from .common import ( FAKE_REFRESH_TOKEN, FAKE_TOKEN, PROJECT_ID, + TEST_CONFIGFLOW_YAML_ONLY, create_config_entry, ) @@ -35,6 +38,7 @@ async def async_setup_sdm(hass): await hass.async_block_till_done() +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth(hass, aioclient_mock): """Exercise authentication library creates valid credentials.""" @@ -84,6 +88,7 @@ async def test_auth(hass, aioclient_mock): assert creds.scopes == SDM_SCOPES +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth_expired_token(hass, aioclient_mock): """Verify behavior of an expired token.""" diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index 843c9b582ae..f199d2ec7dd 100644 --- a/tests/components/nest/test_config_flow_legacy.py +++ b/tests/components/nest/test_config_flow_legacy.py @@ -13,15 +13,6 @@ from tests.common import MockConfigEntry CONFIG = TEST_CONFIG_LEGACY.config -async def test_abort_if_no_implementation_registered(hass): - """Test we abort if no implementation is registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" - - async def test_abort_if_single_instance_allowed(hass): """Test we abort if Nest is already setup.""" existing_entry = MockConfigEntry(domain=DOMAIN, data={}) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index ff55c1f518d..f4299808bf0 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,5 +1,8 @@ """Test the Google Nest Device Access config flow.""" +from __future__ import annotations + +from typing import Any from unittest.mock import patch from google_nest_sdm.exceptions import ( @@ -12,23 +15,31 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .common import ( APP_AUTH_DOMAIN, CLIENT_ID, + CLIENT_SECRET, CLOUD_PROJECT_ID, FAKE_TOKEN, PROJECT_ID, SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, - TEST_CONFIGFLOW_HYBRID, + TEST_CONFIGFLOW_APP_CREDS, TEST_CONFIGFLOW_YAML_ONLY, WEB_AUTH_DOMAIN, MockConfigEntry, + NestTestConfig, ) WEB_REDIRECT_URL = "https://example.com/auth/external/callback" @@ -49,17 +60,35 @@ class OAuthFixture: self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock - async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: - """Invoke flow to puth the auth type to use for this flow.""" - assert result["type"] == "form" - assert result["step_id"] == "pick_implementation" + async def async_app_creds_flow( + self, + result: dict, + cloud_project_id: str = CLOUD_PROJECT_ID, + project_id: str = PROJECT_ID, + ) -> None: + """Invoke multiple steps in the app credentials based flow.""" + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - return await self.async_configure(result, {"implementation": auth_domain}) + result = await self.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" - async def async_oauth_web_flow(self, result: dict) -> None: + result = await self.async_configure(result, {"project_id": PROJECT_ID}) + await self.async_oauth_web_flow(result) + + async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None: """Invoke the oauth flow for Web Auth with fake responses.""" state = self.create_state(result, WEB_REDIRECT_URL) - assert result["url"] == self.authorize_url(state, WEB_REDIRECT_URL) + assert result["type"] == "external" + assert result["url"] == self.authorize_url( + state, + WEB_REDIRECT_URL, + CLIENT_ID, + project_id, + ) # Simulate user redirect back with auth code client = await self.hass_client() @@ -69,38 +98,26 @@ class OAuthFixture: await self.async_mock_refresh(result) - async def async_oauth_app_flow(self, result: dict) -> None: - """Invoke the oauth flow for Installed Auth with fake responses.""" - # Render form with a link to get an auth token - assert result["type"] == "form" - assert result["step_id"] == "auth" - assert "description_placeholders" in result - assert "url" in result["description_placeholders"] - state = self.create_state(result, APP_REDIRECT_URL) - assert result["description_placeholders"]["url"] == self.authorize_url( - state, APP_REDIRECT_URL - ) - # Simulate user entering auth token in form - await self.async_mock_refresh(result, {"code": "abcd"}) - - async def async_reauth(self, old_data: dict) -> dict: + async def async_reauth(self, config_entry: ConfigEntry) -> dict: """Initiate a reuath flow.""" - result = await self.hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_data - ) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" + config_entry.async_start_reauth(self.hass) + await self.hass.async_block_till_done() # Advance through the reauth flow - flows = self.hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + result = self.async_progress() + assert result["step_id"] == "reauth_confirm" # Advance to the oauth flow return await self.hass.config_entries.flow.async_configure( - flows[0]["flow_id"], {} + result["flow_id"], {} ) + def async_progress(self) -> FlowResult: + """Return the current step of the config flow.""" + flows = self.hass.config_entries.flow.async_progress() + assert len(flows) == 1 + return flows[0] + def create_state(self, result: dict, redirect_url: str) -> str: """Create state object based on redirect url.""" return config_entry_oauth2_flow._encode_jwt( @@ -111,11 +128,13 @@ class OAuthFixture: }, ) - def authorize_url(self, state: str, redirect_url: str) -> str: + def authorize_url( + self, state: str, redirect_url: str, client_id: str, project_id: str + ) -> str: """Generate the expected authorization url.""" - oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=project_id) return ( - f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + f"{oauth_authorize}?response_type=code&client_id={client_id}" f"&redirect_uri={redirect_url}" f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" "+https://www.googleapis.com/auth/pubsub" @@ -146,13 +165,16 @@ class OAuthFixture: await self.hass.async_block_till_done() return self.get_config_entry() - async def async_configure(self, result: dict, user_input: dict) -> dict: + async def async_configure( + self, result: dict[str, Any], user_input: dict[str, Any] + ) -> dict: """Advance to the next step in the config flow.""" return await self.hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], + user_input, ) - async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> ConfigEntry: + async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> None: """Verify the pubsub creation step.""" # Render form with a link to get an auth token assert result["type"] == "form" @@ -164,7 +186,7 @@ class OAuthFixture: def get_config_entry(self) -> ConfigEntry: """Get the config entry.""" entries = self.hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert len(entries) >= 1 return entries[0] @@ -174,42 +196,209 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_web_full_flow(hass, oauth, setup_platform): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_app_credentials(hass, oauth, subscriber, setup_platform): """Check full flow.""" await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await oauth.async_app_creds_flow(result) - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) - - await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_restart(hass, oauth, subscriber, setup_platform): + """Check with auth implementation is re-initialized when aborting the flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + # At this point, we should have a valid auth implementation configured. + # Simulate aborting the flow and starting over to ensure we get prompted + # again to configure everything. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + # Change the values to show they are reflected below + result = await oauth.async_configure( + result, {"cloud_project_id": "new-cloud-project-id"} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": "new-project-id"}) + await oauth.async_oauth_web_flow(result, "new-project-id") + + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert "projects/new-cloud-project-id/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": "new-cloud-project-id", + "project_id": "new-project-id", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_wrong_project_id(hass, oauth, subscriber, setup_platform): + """Check the case where the wrong project ids are entered.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + # Enter the cloud project id instead of device access project id (really we just check + # they are the same value which is never correct) + result = await oauth.async_configure(result, {"project_id": CLOUD_PROJECT_ID}) + assert result["type"] == "form" + assert "errors" in result + assert "project_id" in result["errors"] + assert result["errors"]["project_id"] == "wrong_project_id" + + # Fix with a correct value and complete the rest of the flow + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) + await oauth.async_oauth_web_flow(result) + await hass.async_block_till_done() + + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_configuration_error( + hass, + oauth, + setup_platform, + mock_subscriber, +): + """Check full flow fails with configuration error.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + mock_subscriber.create_subscription.side_effect = ConfigurationException + result = await oauth.async_configure(result, {"code": "1234"}) + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "bad_project_id" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_subscriber_error( + hass, oauth, setup_platform, mock_subscriber +): + """Check full flow with a subscriber error.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + mock_subscriber.create_subscription.side_effect = SubscriberException() + result = await oauth.async_configure(result, {"code": "1234"}) + + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "subscriber_error" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_config_yaml_ignored(hass, oauth, setup_platform): + """Check full flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" @pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) async def test_web_reauth(hass, oauth, setup_platform, config_entry): """Test Nest reauthentication.""" - await setup_platform() assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - result = await oauth.async_reauth(config_entry.data) + orig_subscriber_id = config_entry.data.get("subscriber_id") + result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) @@ -223,7 +412,7 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry): "expires_in": 60, } assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated async def test_single_config_entry(hass, setup_platform): @@ -237,7 +426,9 @@ async def test_single_config_entry(hass, setup_platform): assert result["reason"] == "single_instance_allowed" -async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): +async def test_unexpected_existing_config_entries( + hass, oauth, setup_platform, config_entry +): """Test Nest reauthentication with multiple existing config entries.""" # Note that this case will not happen in the future since only a single # instance is now allowed, but this may have been allowed in the past. @@ -246,23 +437,29 @@ async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): await setup_platform() old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} + domain=DOMAIN, + data={ + **config_entry.data, + "extra_data": True, + }, ) old_entry.add_to_hass(hass) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 2 + orig_subscriber_id = config_entry.data.get("subscriber_id") + # Invoke the reauth flow - result = await oauth.async_reauth(old_entry.data) + result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) await oauth.async_finish_setup(result) - # Only a single entry now exists, and the other was cleaned up + # Only reauth entry was updated, the other entry is preserved entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert len(entries) == 2 entry = entries[0] assert entry.unique_id == DOMAIN entry.data["token"].pop("expires_at") @@ -272,7 +469,14 @@ async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): "type": "Bearer", "expires_in": 60, } - assert "subscriber_id" not in entry.data # not updated + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + assert not entry.data.get("extra_data") + + # Other entry was not refreshed + entry = entries[1] + entry.data["token"].pop("expires_at") + assert entry.data.get("token", {}).get("access_token") == "some-token" + assert entry.data.get("extra_data") async def test_reauth_missing_config_entry(hass, setup_platform): @@ -287,42 +491,51 @@ async def test_reauth_missing_config_entry(hass, setup_platform): assert result["reason"] == "missing_configuration" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_app_full_flow(hass, oauth, setup_platform): - """Check full flow.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - - await oauth.async_oauth_app_flow(result) - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - assert entry.title == "OAuth for Apps" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data - - @pytest.mark.parametrize( - "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, APP_AUTH_DOMAIN)] + "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] ) -async def test_app_reauth(hass, oauth, setup_platform, config_entry): - """Test Nest reauthentication for Installed App Auth.""" +async def test_app_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test reauth for deprecated app auth credentails upgrade instructions.""" await setup_platform() - result = await oauth.async_reauth(config_entry.data) - await oauth.async_oauth_app_flow(result) + orig_subscriber_id = config_entry.data.get("subscriber_id") + assert config_entry.data["auth_implementation"] == APP_AUTH_DOMAIN + + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "auth_upgrade" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" + await hass.async_block_till_done() + # Config flow is aborted, but new one created back in re-auth state waiting for user + # to create application credentials + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + # Emulate user entering credentials (different from configuration.yaml creds) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + # Config flow is placed back into a reuath state + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project_upgrade" + + # Frontend sends user back through the config flow again + result = await oauth.async_configure(result, {}) + await oauth.async_oauth_web_flow(result) # Verify existing tokens are replaced entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -334,29 +547,28 @@ async def test_app_reauth(hass, oauth, setup_platform, config_entry): "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated + assert entry.data["auth_implementation"] == DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + + # Existing entry is updated + assert config_entry.data["auth_implementation"] == DOMAIN -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription(hass, oauth, subscriber, setup_platform): - """Check flow that creates a pub/sub subscription.""" +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, WEB_AUTH_DOMAIN)] +) +async def test_web_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test Nest reauthentication for Installed App Auth.""" + await setup_platform() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + orig_subscriber_id = config_entry.data.get("subscriber_id") - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) - assert entry.title == "OAuth for Apps" - assert "token" in entry.data + # Verify existing tokens are replaced + entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN assert entry.data["token"] == { @@ -365,11 +577,11 @@ async def test_pubsub_subscription(hass, oauth, subscriber, setup_platform): "type": "Bearer", "expires_in": 60, } - assert "subscriber_id" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_strip_whitespace( hass, oauth, subscriber, setup_platform ): @@ -379,16 +591,12 @@ async def test_pubsub_subscription_strip_whitespace( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} + await oauth.async_app_creds_flow( + result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) - assert entry.title == "OAuth for Apps" + assert entry.title == "Import from configuration.yaml" assert "token" in entry.data entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN @@ -402,7 +610,7 @@ async def test_pubsub_subscription_strip_whitespace( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_auth_failure( hass, oauth, setup_platform, mock_subscriber ): @@ -412,102 +620,25 @@ async def test_pubsub_subscription_auth_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) mock_subscriber.create_subscription.side_effect = AuthException() - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + await oauth.async_app_creds_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) assert result["type"] == "abort" assert result["reason"] == "invalid_access_token" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription_failure( - hass, oauth, setup_platform, mock_subscriber -): - """Check flow that creates a pub/sub subscription.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - - mock_subscriber.create_subscription.side_effect = SubscriberException() - - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "subscriber_error" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription_configuration_failure( - hass, oauth, setup_platform, mock_subscriber -): - """Check flow that creates a pub/sub subscription.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - - mock_subscriber.create_subscription.side_effect = ConfigurationException() - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "bad_project_id" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_with_wrong_project_id(hass, oauth, setup_platform): - """Test a possible common misconfiguration mixing up project ids.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure( - result, {"cloud_project_id": PROJECT_ID} # SDM project id - ) - await hass.async_block_till_done() - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "wrong_project_id" - - -@pytest.mark.parametrize( - "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] -) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_pubsub_subscriber_config_entry_reauth( - hass, oauth, setup_platform, subscriber, config_entry + hass, oauth, setup_platform, subscriber, config_entry, auth_implementation ): """Test the pubsub subscriber id is preserved during reauth.""" await setup_platform() - result = await oauth.async_reauth(config_entry.data) - await oauth.async_oauth_app_flow(result) + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) # Entering an updated access token refreshs the config entry. entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -519,12 +650,12 @@ async def test_pubsub_subscriber_config_entry_reauth( "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN + assert entry.data["auth_implementation"] == auth_implementation assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscriber): """Test that the Google Home name is used for the config entry title.""" @@ -547,22 +678,16 @@ async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscri result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_multiple_homes( hass, oauth, setup_platform, subscriber ): @@ -599,18 +724,13 @@ async def test_config_entry_title_multiple_homes( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home #1, Example Home #2" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscriber): """Test exception handling when determining the structure names.""" await setup_platform() @@ -618,24 +738,17 @@ async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscrib result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) mock_subscriber.async_get_device_manager.side_effect = AuthException() - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - - assert entry.title == "OAuth for Apps" + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + assert entry.title == "Import from configuration.yaml" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): """Test handling the case where a structure has no name set.""" @@ -655,34 +768,33 @@ async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) # Fallback to default name - assert entry.title == "OAuth for Apps" + assert entry.title == "Import from configuration.yaml" -async def test_dhcp_discovery_without_config(hass, oauth): - """Exercise discovery dhcp with no config present (can't run).""" +@pytest.mark.parametrize("nest_test_config", [NestTestConfig()]) +async def test_dhcp_discovery(hass, oauth, subscriber): + """Exercise discovery dhcp starts the config flow and kicks user to frontend creds flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=FAKE_DHCP_DATA, ) await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "missing_configuration" + assert result["type"] == "form" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_dhcp_discovery(hass, oauth, setup_platform): - """Discover via dhcp when config is present.""" +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_dhcp_discovery_with_creds(hass, oauth, subscriber, setup_platform): + """Exercise discovery dhcp with no config present (can't run).""" await setup_platform() result = await hass.config_entries.flow.async_init( @@ -691,19 +803,33 @@ async def test_dhcp_discovery(hass, oauth, setup_platform): data=FAKE_DHCP_DATA, ) await hass.async_block_till_done() + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - # DHCP discovery invokes the config flow - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - - # Discovery does not run once configured - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=FAKE_DHCP_DATA, - ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "already_configured" + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 8e28222e356..85b63b23301 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -127,8 +127,6 @@ async def test_setup_susbcriber_failure( ): """Test configuration error.""" with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", side_effect=SubscriberException(), ): diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 28550bd57b6..83845586764 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -443,9 +443,7 @@ async def test_structure_update_event(hass, subscriber, setup_platform): }, auth=None, ) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + with patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=subscriber, ): diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 381252c6f75..1b473ccd62f 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -25,10 +25,13 @@ from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState from .common import ( + PROJECT_ID, + SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, + TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, - NestTestConfig, YieldFixture, ) @@ -170,7 +173,8 @@ async def test_subscriber_configuration_failure( @pytest.mark.parametrize( - "nest_test_config", [NestTestConfig(config={}, config_entry_data=None)] + "nest_test_config", + [TEST_CONFIGFLOW_APP_CREDS], ) async def test_empty_config(hass, error_caplog, config, setup_platform): """Test setup is a no-op with not config.""" @@ -205,8 +209,12 @@ async def test_unload_entry(hass, setup_platform): TEST_CONFIG_HYBRID, True, ), # Integration created subscriber, garbage collect on remove + ( + TEST_CONFIG_APP_CREDS, + True, + ), # Integration created subscriber, garbage collect on remove ], - ids=["yaml-config-only", "hybrid-config"], + ids=["yaml-config-only", "hybrid-config", "config-entry"], ) async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_called): """Test successful unload of a ConfigEntry.""" @@ -220,6 +228,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED + # Assert entry was imported if from configuration.yaml + assert entry.data.get("subscriber_id") == SUBSCRIBER_ID + assert entry.data.get("project_id") == PROJECT_ID with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.subscriber_id" @@ -234,7 +245,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ @pytest.mark.parametrize( - "nest_test_config", [TEST_CONFIG_HYBRID], ids=["hyrbid-config"] + "nest_test_config", + [TEST_CONFIG_HYBRID, TEST_CONFIG_APP_CREDS], + ids=["hyrbid-config", "app-creds"], ) async def test_remove_entry_delete_subscriber_failure( hass, nest_test_config, setup_base_platform