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 <marhje52@gmail.com>

* 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 <marhje52@gmail.com>

* Remove outdated code

* Update homeassistant/components/nest/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-06-15 07:15:53 -07:00 committed by GitHub
parent f8f1bfde21
commit b014d558ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 856 additions and 517 deletions

View file

@ -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)

View file

@ -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(

View file

@ -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",
}

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -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"],

View file

@ -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": {

View file

@ -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": {

View file

@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [
"home_connect",
"lyric",
"neato",
"nest",
"netatmo",
"senz",
"spotify",

View file

@ -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(

View file

@ -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,
)

View file

@ -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)

View file

@ -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."""

View file

@ -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={})

View file

@ -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",
},
}

View file

@ -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(),
):

View file

@ -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,
):

View file

@ -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