mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
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:
parent
f8f1bfde21
commit
b014d558ff
@ -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)
|
||||
|
@ -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(
|
||||
|
24
homeassistant/components/nest/application_credentials.py
Normal file
24
homeassistant/components/nest/application_credentials.py
Normal 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",
|
||||
}
|
@ -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
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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"],
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"home_connect",
|
||||
"lyric",
|
||||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
"senz",
|
||||
"spotify",
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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={})
|
||||
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
@ -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(),
|
||||
):
|
||||
|
@ -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,
|
||||
):
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user