diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index abe9dc707c390d..9f35605cd6cfe3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -4,10 +4,6 @@ "codeowners": ["@bdraco"], "config_flow": true, "dhcp": [ - { - "hostname": "yale-connect-plus", - "macaddress": "00177A*" - }, { "hostname": "connect", "macaddress": "D86162*" diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index 35535f4395f989..f7a4a6e0f4da05 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -6,7 +6,9 @@ from typing import cast from aiohttp import ClientResponseError -from yalexs.exceptions import AugustApiAIOHTTPError +from yalexs.const import Brand +from yalexs.exceptions import YaleApiError +from yalexs.manager.const import CONF_BRAND from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from yalexs.manager.gateway import Config as YaleXSConfig @@ -14,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr from .const import DOMAIN, PLATFORMS from .data import YaleData @@ -27,14 +29,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up yale from a config entry.""" session = async_create_yale_clientsession(hass) - yale_gateway = YaleGateway(Path(hass.config.config_dir), session) + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + yale_gateway = YaleGateway(Path(hass.config.config_dir), session, oauth_session) try: await async_setup_yale(hass, entry, yale_gateway) except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to yale api") from err - except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: + except (YaleApiError, ClientResponseError, CannotConnect) as err: raise ConfigEntryNotReady from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -50,7 +58,7 @@ async def async_setup_yale( ) -> None: """Set up the yale component.""" config = cast(YaleXSConfig, entry.data) - await yale_gateway.async_setup(config) + await yale_gateway.async_setup({**config, CONF_BRAND: Brand.YALE_GLOBAL}) await yale_gateway.async_authenticate() await yale_gateway.async_refresh_access_token_if_needed() data = entry.runtime_data = YaleData(hass, yale_gateway) diff --git a/homeassistant/components/yale/application_credentials.py b/homeassistant/components/yale/application_credentials.py new file mode 100644 index 00000000000000..31b5b7a92c787b --- /dev/null +++ b/homeassistant/components/yale/application_credentials.py @@ -0,0 +1,15 @@ +"""application_credentials platform the yale integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +OAUTH2_AUTHORIZE = "https://oauth.aaecosystem.com/authorize" +OAUTH2_TOKEN = "https://oauth.aaecosystem.com/access_token" + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/yale/config_flow.py b/homeassistant/components/yale/config_flow.py index 652bb7d0ef536e..cdd44754103721 100644 --- a/homeassistant/components/yale/config_flow.py +++ b/homeassistant/components/yale/config_flow.py @@ -1,282 +1,57 @@ """Config flow for Yale integration.""" from collections.abc import Mapping -from dataclasses import dataclass import logging -from pathlib import Path from typing import Any -import aiohttp -import voluptuous as vol -from yalexs.authenticator import ValidationResult -from yalexs.const import BRANDS -from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation +import jwt -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow -from .const import ( - CONF_ACCESS_TOKEN_CACHE_FILE, - CONF_BRAND, - CONF_LOGIN_METHOD, - DEFAULT_BRAND, - DEFAULT_LOGIN_METHOD, - DOMAIN, - LOGIN_METHODS, - VERIFICATION_CODE_KEY, -) -from .gateway import YaleGateway -from .util import async_create_yale_clientsession +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_validate_input( - data: dict[str, Any], yale_gateway: YaleGateway -) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - - Request configuration steps from the user. - """ - assert yale_gateway.authenticator is not None - authenticator = yale_gateway.authenticator - if (code := data.get(VERIFICATION_CODE_KEY)) is not None: - result = await authenticator.async_validate_verification_code(code) - _LOGGER.debug("Verification code validation: %s", result) - if result != ValidationResult.VALIDATED: - raise RequireValidation - - try: - await yale_gateway.async_authenticate() - except RequireValidation: - _LOGGER.debug( - "Requesting new verification code for %s via %s", - data.get(CONF_USERNAME), - data.get(CONF_LOGIN_METHOD), - ) - if code is None: - await yale_gateway.authenticator.async_send_verification_code() - raise - - return { - "title": data.get(CONF_USERNAME), - "data": yale_gateway.config_entry(), - } - - -@dataclass(slots=True) -class ValidateResult: - """Result from validation.""" - - validation_required: bool - info: dict[str, Any] - errors: dict[str, str] - description_placeholders: dict[str, str] - - -class YaleConfigFlow(ConfigFlow, domain=DOMAIN): +class YaleConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Yale.""" VERSION = 1 + DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None - def __init__(self) -> None: - """Store an YaleGateway().""" - self._yale_gateway: YaleGateway | None = None - self._aiohttp_session: aiohttp.ClientSession | None = None - self._user_auth_details: dict[str, Any] = {} - self._needs_reset = True - self._mode: str | None = None - super().__init__() - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - return await self.async_step_user_validate() - - async def async_step_user_validate( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle authentication.""" - errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} - if user_input is not None: - self._user_auth_details.update(user_input) - validate_result = await self._async_auth_or_validate() - description_placeholders = validate_result.description_placeholders - if validate_result.validation_required: - return await self.async_step_validation() - if not (errors := validate_result.errors): - return await self._async_update_or_create_entry(validate_result.info) - - return self.async_show_form( - step_id="user_validate", - data_schema=vol.Schema( - { - vol.Required( - CONF_BRAND, - default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), - ): vol.In(BRANDS), - vol.Required( - CONF_LOGIN_METHOD, - default=self._user_auth_details.get( - CONF_LOGIN_METHOD, DEFAULT_LOGIN_METHOD - ), - ): vol.In(LOGIN_METHODS), - vol.Required( - CONF_USERNAME, - default=self._user_auth_details.get(CONF_USERNAME), - ): str, - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - description_placeholders=description_placeholders, - ) + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER - async def async_step_validation( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle validation (2fa) step.""" - if user_input: - if self._mode == "reauth": - return await self.async_step_reauth_validate(user_input) - return await self.async_step_user_validate(user_input) - - previously_failed = VERIFICATION_CODE_KEY in self._user_auth_details - return self.async_show_form( - step_id="validation", - data_schema=vol.Schema( - {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} - ), - errors={"base": "invalid_verification_code"} if previously_failed else None, - description_placeholders={ - CONF_BRAND: self._user_auth_details[CONF_BRAND], - CONF_USERNAME: self._user_auth_details[CONF_USERNAME], - CONF_LOGIN_METHOD: self._user_auth_details[CONF_LOGIN_METHOD], - }, - ) - - @callback - def _async_get_gateway(self) -> YaleGateway: - """Set up the gateway.""" - if self._yale_gateway is not None: - return self._yale_gateway - self._aiohttp_session = async_create_yale_clientsession(self.hass) - self._yale_gateway = YaleGateway( - Path(self.hass.config.config_dir), self._aiohttp_session - ) - return self._yale_gateway - - @callback - def _async_shutdown_gateway(self) -> None: - """Shutdown the gateway.""" - if self._aiohttp_session is not None: - self._aiohttp_session.detach() - self._yale_gateway = None - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._user_auth_details = dict(entry_data) - self._mode = "reauth" - self._needs_reset = True - return await self.async_step_reauth_validate() - - async def async_step_reauth_validate( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauth and validation.""" - errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} - if user_input is not None: - self._user_auth_details.update(user_input) - validate_result = await self._async_auth_or_validate() - description_placeholders = validate_result.description_placeholders - if validate_result.validation_required: - return await self.async_step_validation() - if not (errors := validate_result.errors): - return await self._async_update_or_create_entry(validate_result.info) - - return self.async_show_form( - step_id="reauth_validate", - data_schema=vol.Schema( - { - vol.Required( - CONF_BRAND, - default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), - ): vol.In(BRANDS), - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - description_placeholders=description_placeholders - | { - CONF_USERNAME: self._user_auth_details[CONF_USERNAME], - }, - ) - - async def _async_reset_access_token_cache_if_needed( - self, gateway: YaleGateway, username: str, access_token_cache_file: str | None - ) -> None: - """Reset the access token cache if needed.""" - # We need to configure the access token cache file before we setup the gateway - # since we need to reset it if the brand changes BEFORE we setup the gateway - gateway.async_configure_access_token_cache_file( - username, access_token_cache_file - ) - if self._needs_reset: - self._needs_reset = False - await gateway.async_reset_authentication() - - async def _async_auth_or_validate(self) -> ValidateResult: - """Authenticate or validate.""" - user_auth_details = self._user_auth_details - gateway = self._async_get_gateway() - assert gateway is not None - await self._async_reset_access_token_cache_if_needed( - gateway, - user_auth_details[CONF_USERNAME], - user_auth_details.get(CONF_ACCESS_TOKEN_CACHE_FILE), - ) - await gateway.async_setup(user_auth_details) - - errors: dict[str, str] = {} - info: dict[str, Any] = {} - description_placeholders: dict[str, str] = {} - validation_required = False - - try: - info = await async_validate_input(user_auth_details, gateway) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except RequireValidation: - validation_required = True - except Exception as ex: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unhandled" - description_placeholders = {"error": str(ex)} - - return ValidateResult( - validation_required, info, errors, description_placeholders - ) - - async def _async_update_or_create_entry( - self, info: dict[str, Any] - ) -> ConfigFlowResult: - """Update existing entry or create a new one.""" - self._async_shutdown_gateway() - - existing_entry = await self.async_set_unique_id( - self._user_auth_details[CONF_USERNAME] - ) - if not existing_entry: - return self.async_create_entry(title=info["title"], data=info["data"]) - - return self.async_update_reload_and_abort(existing_entry, data=info["data"]) + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + + def _async_get_user_id_from_access_token(self, encoded: str) -> str: + """Get user ID from access token.""" + decoded = jwt.decode( + encoded, + "", + verify=False, + options={"verify_signature": False}, + algorithms=["HS256"], + ) + return decoded["userId"] + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + user_id = self._async_get_user_id_from_access_token( + data["token"]["access_token"] + ) + if entry := self.reauth_entry: + if entry.unique_id != user_id: + return self.async_abort(reason="reauth_invalid_user") + return self.async_update_reload_and_abort(entry, data=data) + await self.async_set_unique_id(user_id) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/yale/gateway.py b/homeassistant/components/yale/gateway.py index 8bf8fc139a6a68..cd7796182d27af 100644 --- a/homeassistant/components/yale/gateway.py +++ b/homeassistant/components/yale/gateway.py @@ -1,30 +1,43 @@ """Handle Yale connection setup and authentication.""" -from typing import Any +import logging +from pathlib import Path +from aiohttp import ClientSession +from yalexs.authenticator_common import Authentication, AuthenticationState from yalexs.manager.gateway import Gateway -from homeassistant.const import CONF_USERNAME +from homeassistant.helpers import config_entry_oauth2_flow -from .const import ( - CONF_ACCESS_TOKEN_CACHE_FILE, - CONF_BRAND, - CONF_INSTALL_ID, - CONF_LOGIN_METHOD, - DEFAULT_BRAND, -) +_LOGGER = logging.getLogger(__name__) class YaleGateway(Gateway): """Handle the connection to Yale.""" - def config_entry(self) -> dict[str, Any]: - """Config entry.""" - assert self._config is not None - return { - CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND), - CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], - CONF_USERNAME: self._config[CONF_USERNAME], - CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), - CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, - } + def __init__( + self, + config_path: Path, + aiohttp_session: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Init the connection.""" + super().__init__(config_path, aiohttp_session) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Get access token.""" + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token["access_token"] + + async def async_refresh_access_token_if_needed(self) -> None: + """Refresh the access token if needed.""" + await self._oauth_session.async_ensure_token_valid() + + async def async_authenticate(self) -> Authentication: + """Authenticate with the details provided to setup.""" + await self._oauth_session.async_ensure_token_valid() + self.authentication = Authentication( + AuthenticationState.AUTHENTICATED, None, None, None + ) + return self.authentication diff --git a/homeassistant/components/yale/strings.json b/homeassistant/components/yale/strings.json index 3024fa702b5bec..3fb1345a3b036c 100644 --- a/homeassistant/components/yale/strings.json +++ b/homeassistant/components/yale/strings.json @@ -1,41 +1,26 @@ { "config": { - "error": { - "unhandled": "Unhandled error: {error}", - "invalid_verification_code": "Invalid verification code", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_invalid_user": "Reauthenticate must use the same account." }, - "step": { - "validation": { - "title": "Two factor authentication", - "data": { - "code": "Verification code" - }, - "description": "Please check your {login_method} ({username}) and enter the verification code below. Codes may take a few minutes to arrive." - }, - "user_validate": { - "description": "It is recommended to use the 'email' login method as some brands may not work with the 'phone' method. If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.", - "data": { - "brand": "Brand", - "login_method": "Login Method", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - }, - "title": "Set up an Yale account" - }, - "reauth_validate": { - "description": "Choose the correct brand for your device, and enter the password for {username}. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.", - "data": { - "brand": "[%key:component::yale::config::step::user_validate::data::brand%]", - "password": "[%key:common::config_flow::data::password%]" - }, - "title": "Reauthenticate an Yale account" - } + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "entity": { diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index dc30f9d76f004b..75fd489bad304e 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -29,6 +29,7 @@ "twitch", "withings", "xbox", + "yale", "yolink", "youtube", ] diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 744007a29b69ee..8f5964f1618100 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -12,11 +12,6 @@ "domain": "airzone", "macaddress": "E84F25*", }, - { - "domain": "august", - "hostname": "yale-connect-plus", - "macaddress": "00177A*", - }, { "domain": "august", "hostname": "connect", diff --git a/tests/components/yale/__init__.py b/tests/components/yale/__init__.py index 7f72d348042d14..f0604940686c46 100644 --- a/tests/components/yale/__init__.py +++ b/tests/components/yale/__init__.py @@ -1 +1,12 @@ """Tests for the yale component.""" + +MOCK_CONFIG_ENTRY_DATA = { + "auth_implementation": "cloud", + "token": { + "access_token": "access_token", + "expires_in": 1, + "refresh_token": "refresh_token", + "expires_at": 2, + "service": "yale", + }, +} diff --git a/tests/components/yale/conftest.py b/tests/components/yale/conftest.py index fb5ec1da8b999f..c890087ad12267 100644 --- a/tests/components/yale/conftest.py +++ b/tests/components/yale/conftest.py @@ -5,6 +5,13 @@ import pytest from yalexs.manager.ratelimit import _RateLimitChecker +from homeassistant.components.yale.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .mocks import mock_client_credentials, mock_config_entry + +from tests.common import MockConfigEntry, load_fixture + @pytest.fixture(name="mock_discovery", autouse=True) def mock_discovery_fixture(): @@ -20,3 +27,33 @@ def disable_ratelimit_checks_fixture(): """Disable rate limit checks.""" with patch.object(_RateLimitChecker, "register_wakeup"): yield + + +@pytest.fixture(name="mock_config_entry") +def mock_config_entry_fixture(jwt: str) -> MockConfigEntry: + """Return the default mocked config entry.""" + return mock_config_entry(jwt=jwt) + + +@pytest.fixture(name="jwt") +def load_jwt_fixture() -> str: + """Load Fixture data.""" + return load_fixture("jwt", DOMAIN).strip("\n") + + +@pytest.fixture(name="reauth_jwt") +def load_reauth_jwt_fixture() -> str: + """Load Fixture data.""" + return load_fixture("reauth_jwt", DOMAIN).strip("\n") + + +@pytest.fixture(name="reauth_jwt_wrong_account") +def load_reauth_jwt_wrong_account_fixture() -> str: + """Load Fixture data.""" + return load_fixture("reauth_jwt_wrong_account", DOMAIN).strip("\n") + + +@pytest.fixture(name="client_credentials", autouse=True) +async def mock_client_credentials_fixture(hass: HomeAssistant) -> None: + """Mock client credentials.""" + await mock_client_credentials(hass) diff --git a/tests/components/yale/fixtures/jwt b/tests/components/yale/fixtures/jwt new file mode 100644 index 00000000000000..d64f31b9bb27f2 --- /dev/null +++ b/tests/components/yale/fixtures/jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImE3NmMyNWU1LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZE91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6W10sInBob25lIjpbXSwiZXhwaXJlc0F0IjoiMjAyNC0xMi0xOFQxMzo1NDowNS4xMzRaIiwidGVtcG9yYXJ5QWNjb3VudENyZWF0aW9uUGFzc3dvcmRMaW5rIjoiIiwiaWF0IjoxNzI0MTYyMDQ1LCJleHAiOjE3MzQ1MzAwNDUsIm9hdXRoIjp7ImFwcF9uYW1lIjoiSG9tZSBBc3Npc3RhbnQiLCJjbGllbnRfaWQiOiJiM2NkM2YwYi1mYjk3LTRkNmMtYmVlOS1hZjdhYjA0NzU4YzciLCJyZWRpcmVjdF91cmkiOiJodHRwczovL2FjY291bnQtbGluay5uYWJ1Y2FzYS5jb20vYXV0aG9yaXplX2NhbGxiYWNrIiwicGFydG5lcl9pZCI6IjY1Nzk3NDg4MTA2NmNhNDhjOTljMDgyNiJ9fQ.BdRo-dEr-osbDQGB2XzlI-mIj4gqULtapODt-sj-eA8 diff --git a/tests/components/yale/fixtures/reauth_jwt b/tests/components/yale/fixtures/reauth_jwt new file mode 100644 index 00000000000000..4db8d061b68605 --- /dev/null +++ b/tests/components/yale/fixtures/reauth_jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImE3NmMyNWU1LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZE91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6W10sInBob25lIjpbXSwiZXhwaXJlc0F0IjoiMjAyNC0xMi0xOFQxMzo1NDowNS4xMzRaIiwidGVtcG9yYXJ5QWNjb3VudENyZWF0aW9uUGFzc3dvcmRMaW5rIjoiIiwiaWF0IjoxNzI0MTYyMDQ1LCJleHAiOjI3MzQ1MzAwNDUsIm9hdXRoIjp7ImFwcF9uYW1lIjoiSG9tZSBBc3Npc3RhbnQiLCJjbGllbnRfaWQiOiJiM2NkM2YwYi1mYjk3LTRkNmMtYmVlOS1hZjdhYjA0NzU4YzciLCJyZWRpcmVjdF91cmkiOiJodHRwczovL2FjY291bnQtbGluay5uYWJ1Y2FzYS5jb20vYXV0aG9yaXplX2NhbGxiYWNrIiwicGFydG5lcl9pZCI6IjY1Nzk3NDg4MTA2NmNhNDhjOTljMDgyNiJ9fQ.DtkHscsvbTE-SyKW3RxwXFQIKMf0xJwfPZN1X3JesqA diff --git a/tests/components/yale/fixtures/reauth_jwt_wrong_account b/tests/components/yale/fixtures/reauth_jwt_wrong_account new file mode 100644 index 00000000000000..b0b624381781ed --- /dev/null +++ b/tests/components/yale/fixtures/reauth_jwt_wrong_account @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6IjQ0NDQ0NDQ0LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZE91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6W10sInBob25lIjpbXSwiZXhwaXJlc0F0IjoiMjAyNC0xMi0xOFQxMzo1NDowNS4xMzRaIiwidGVtcG9yYXJ5QWNjb3VudENyZWF0aW9uUGFzc3dvcmRMaW5rIjoiIiwiaWF0IjoxNzI0MTYyMDQ1LCJleHAiOjE3MzQ1MzAwNDUsIm9hdXRoIjp7ImFwcF9uYW1lIjoiSG9tZSBBc3Npc3RhbnQiLCJjbGllbnRfaWQiOiJiM2NkM2YwYi1mYjk3LTRkNmMtYmVlOS1hZjdhYjA0NzU4YzciLCJyZWRpcmVjdF91cmkiOiJodHRwczovL2FjY291bnQtbGluay5uYWJ1Y2FzYS5jb20vYXV0aG9yaXplX2NhbGxiYWNrIiwicGFydG5lcl9pZCI6IjY1Nzk3NDg4MTA2NmNhNDhjOTljMDgyNiJ9fQ.PenDp4JUIBQZEx2BFxaCqV1-6yMuUPtmnB6jq1wpoX8 diff --git a/tests/components/yale/mocks.py b/tests/components/yale/mocks.py index 298d513145ab97..e73abfafcbb867 100644 --- a/tests/components/yale/mocks.py +++ b/tests/components/yale/mocks.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable +from contextlib import contextmanager import json import os import time @@ -18,6 +19,7 @@ ACTIVITY_ACTIONS_LOCK_OPERATION, SOURCE_LOCK_OPERATE, SOURCE_LOG, + Activity, BridgeOperationActivity, DoorbellDingActivity, DoorbellMotionActivity, @@ -25,101 +27,183 @@ DoorOperationActivity, LockOperationActivity, ) -from yalexs.authenticator import AuthenticationState +from yalexs.api_async import ApiAsync +from yalexs.authenticator_common import Authentication, AuthenticationState from yalexs.const import Brand from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.lock import Lock, LockDetail +from yalexs.manager.ratelimit import _RateLimitChecker from yalexs.pubnub_async import AugustPubNub -from homeassistant.components.yale.const import CONF_BRAND, CONF_LOGIN_METHOD, DOMAIN +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yale.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081" + -def _mock_get_config(brand: Brand = Brand.YALE_HOME): +def _mock_get_config( + brand: Brand = Brand.YALE_GLOBAL, jwt: str | None = None +) -> dict[str, Any]: """Return a default yale config.""" return { DOMAIN: { - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "mocked_username", - CONF_PASSWORD: "mocked_password", - CONF_BRAND: brand, + "auth_implementation": "yale", + "token": { + "access_token": jwt or "access_token", + "expires_in": 1, + "refresh_token": "refresh_token", + "expires_at": time.time() + 3600, + "service": "yale", + }, } } -def _mock_authenticator(auth_state): +def _mock_authenticator(auth_state: AuthenticationState) -> Authentication: """Mock an yale authenticator.""" authenticator = MagicMock() type(authenticator).state = PropertyMock(return_value=auth_state) return authenticator -def _timetoken(): +def _timetoken() -> str: return str(time.time_ns())[:-2] -@patch("yalexs.manager.gateway.ApiAsync") -@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate") -async def _mock_setup_yale( - hass, api_instance, pubnub_mock, authenticate_mock, api_mock, brand -): - """Set up yale integration.""" - authenticate_mock.side_effect = MagicMock( - return_value=_mock_yale_authentication( - "original_token", 1234, AuthenticationState.AUTHENTICATED - ) - ) - api_mock.return_value = api_instance - entry = MockConfigEntry( +async def mock_yale_config_entry( + hass: HomeAssistant, +) -> MockConfigEntry: + """Mock yale config entry and client credentials.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + return entry + + +def mock_config_entry(jwt: str | None = None) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( domain=DOMAIN, - data=_mock_get_config(brand)[DOMAIN], + data=_mock_get_config(jwt=jwt)[DOMAIN], options={}, + unique_id=USER_ID, ) - entry.add_to_hass(hass) + + +async def mock_client_credentials(hass: HomeAssistant) -> ClientCredential: + """Mock client credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("1", "2"), + DOMAIN, + ) + + +@contextmanager +def patch_yale_setup(): + """Patch yale setup process.""" with ( + patch("yalexs.manager.gateway.ApiAsync") as api_mock, + patch.object(_RateLimitChecker, "register_wakeup") as authenticate_mock, + patch("yalexs.manager.data.async_create_pubnub", return_value=AsyncMock()), + patch("yalexs.manager.data.AugustPubNub") as pubnub_mock, patch( - "yalexs.manager.data.async_create_pubnub", - return_value=AsyncMock(), + "homeassistant.components.yale.config_entry_oauth2_flow.async_get_config_entry_implementation" ), - patch("yalexs.manager.data.AugustPubNub", return_value=pubnub_mock), ): - assert await hass.config_entries.async_setup(entry.entry_id) + yield api_mock, authenticate_mock, pubnub_mock + + +async def _mock_setup_yale( + hass: HomeAssistant, + api_instance: ApiAsync, + pubnub_mock: AugustPubNub, + authenticate_side_effect: MagicMock, +) -> ConfigEntry: + """Set up yale integration.""" + entry = await mock_yale_config_entry(hass) + with patch_yale_setup() as patched_setup: + api_mock, authenticate_mock, pubnub_mock_ = patched_setup + authenticate_mock.side_effect = authenticate_side_effect + pubnub_mock_.return_value = pubnub_mock + api_mock.return_value = api_instance + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry async def _create_yale_with_devices( hass: HomeAssistant, - devices: Iterable[LockDetail | DoorbellDetail], + devices: Iterable[LockDetail | DoorbellDetail] | None = None, api_call_side_effects: dict[str, Any] | None = None, activities: list[Any] | None = None, pubnub: AugustPubNub | None = None, brand: Brand = Brand.YALE_HOME, + authenticate_side_effect: MagicMock | None = None, ) -> ConfigEntry: entry, _ = await _create_yale_api_with_devices( - hass, devices, api_call_side_effects, activities, pubnub, brand + hass, + devices, + api_call_side_effects, + activities, + pubnub, + brand, + authenticate_side_effect, ) return entry async def _create_yale_api_with_devices( - hass, - devices, - api_call_side_effects=None, - activities=None, - pubnub=None, - brand=Brand.YALE_HOME, -): - if api_call_side_effects is None: - api_call_side_effects = {} + hass: HomeAssistant, + devices: Iterable[LockDetail | DoorbellDetail] | None = None, + api_call_side_effects: dict[str, Any] | None = None, + activities: dict[str, Any] | None = None, + pubnub: AugustPubNub | None = None, + brand: Brand = Brand.YALE_HOME, + authenticate_side_effect: MagicMock | None = None, +) -> tuple[ConfigEntry, ApiAsync]: if pubnub is None: pubnub = AugustPubNub() + if api_call_side_effects is None: + api_call_side_effects = {} + if devices is None: + devices = () + + update_api_call_side_effects(api_call_side_effects, devices, activities) + + api_instance = await make_mock_api(api_call_side_effects, brand) + entry = await _mock_setup_yale( + hass, + api_instance, + pubnub, + authenticate_side_effect=authenticate_side_effect, + ) + if any(device for device in devices if isinstance(device, LockDetail)): + # Ensure we sync status when the integration is loaded if there + # are any locks + assert api_instance.async_status_async.mock_calls + + return entry, api_instance + + +def update_api_call_side_effects( + api_call_side_effects: dict[str, Any], + devices: Iterable[LockDetail | DoorbellDetail], + activities: dict[str, Any] | None = None, +) -> None: + """Update side effects dict from devices and activities.""" + device_data = {"doorbells": [], "locks": []} - for device in devices: + for device in devices or (): if isinstance(device, LockDetail): device_data["locks"].append( {"base": _mock_yale_lock(device.device_id), "detail": device} @@ -202,21 +286,12 @@ def unlock_return_activities_side_effect(access_token, device_id): "async_unlatch_return_activities", unlock_return_activities_side_effect ) - api_instance, entry = await _mock_setup_yale_with_api_side_effects( - hass, api_call_side_effects, pubnub, brand - ) - - if device_data["locks"]: - # Ensure we sync status when the integration is loaded if there - # are any locks - assert api_instance.async_status_async.mock_calls - return entry, api_instance - - -async def _mock_setup_yale_with_api_side_effects( - hass, api_call_side_effects, pubnub, brand=Brand.YALE_HOME -): +async def make_mock_api( + api_call_side_effects: dict[str, Any], + brand: Brand = Brand.YALE_HOME, +) -> ApiAsync: + """Make a mock ApiAsync instance.""" api_instance = MagicMock(name="Api", brand=brand) if api_call_side_effects["get_lock_detail"]: @@ -266,10 +341,12 @@ async def _mock_setup_yale_with_api_side_effects( api_instance.async_unlatch_async = AsyncMock() api_instance.async_unlatch = AsyncMock() - return api_instance, await _mock_setup_yale(hass, api_instance, pubnub, brand=brand) + return api_instance -def _mock_yale_authentication(token_text, token_timestamp, state): +def _mock_yale_authentication( + token_text: str, token_timestamp: float, state: AuthenticationState +) -> Authentication: authentication = MagicMock(name="yalexs.authentication") type(authentication).state = PropertyMock(return_value=state) type(authentication).access_token = PropertyMock(return_value=token_text) @@ -279,13 +356,13 @@ def _mock_yale_authentication(token_text, token_timestamp, state): return authentication -def _mock_yale_lock(lockid="mocklockid1", houseid="mockhouseid1"): +def _mock_yale_lock(lockid: str = "mocklockid1", houseid: str = "mockhouseid1") -> Lock: return Lock(lockid, _mock_yale_lock_data(lockid=lockid, houseid=houseid)) def _mock_yale_doorbell( deviceid="mockdeviceid1", houseid="mockhouseid1", brand=Brand.YALE_HOME -): +) -> Doorbell: return Doorbell( deviceid, _mock_yale_doorbell_data(deviceid=deviceid, houseid=houseid, brand=brand), @@ -293,8 +370,10 @@ def _mock_yale_doorbell( def _mock_yale_doorbell_data( - deviceid="mockdeviceid1", houseid="mockhouseid1", brand=Brand.YALE_HOME -): + deviceid: str = "mockdeviceid1", + houseid: str = "mockhouseid1", + brand: Brand = Brand.YALE_HOME, +) -> dict[str, Any]: return { "_id": deviceid, "DeviceID": deviceid, @@ -314,7 +393,9 @@ def _mock_yale_doorbell_data( } -def _mock_yale_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): +def _mock_yale_lock_data( + lockid: str = "mocklockid1", houseid: str = "mockhouseid1" +) -> dict[str, Any]: return { "_id": lockid, "LockID": lockid, @@ -333,19 +414,21 @@ def _mock_yale_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): } -async def _mock_operative_yale_lock_detail(hass): +async def _mock_operative_yale_lock_detail(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.online.json") -async def _mock_lock_with_offline_key(hass): +async def _mock_lock_with_offline_key(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.online_with_keys.json") -async def _mock_inoperative_yale_lock_detail(hass): +async def _mock_inoperative_yale_lock_detail(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.offline.json") -async def _mock_activities_from_fixture(hass, path): +async def _mock_activities_from_fixture( + hass: HomeAssistant, path: str +) -> list[Activity]: json_dict = await _load_json_fixture(hass, path) activities = [] for activity_json in json_dict: @@ -356,36 +439,38 @@ async def _mock_activities_from_fixture(hass, path): return activities -async def _mock_lock_from_fixture(hass, path): +async def _mock_lock_from_fixture(hass: HomeAssistant, path: str) -> LockDetail: json_dict = await _load_json_fixture(hass, path) return LockDetail(json_dict) -async def _mock_doorbell_from_fixture(hass, path): +async def _mock_doorbell_from_fixture(hass: HomeAssistant, path: str) -> LockDetail: json_dict = await _load_json_fixture(hass, path) return DoorbellDetail(json_dict) -async def _load_json_fixture(hass, path): +async def _load_json_fixture(hass: HomeAssistant, path: str) -> dict[str, Any]: fixture = await hass.async_add_executor_job( load_fixture, os.path.join("yale", path) ) return json.loads(fixture) -async def _mock_doorsense_enabled_yale_lock_detail(hass): +async def _mock_doorsense_enabled_yale_lock_detail(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json") -async def _mock_doorsense_missing_yale_lock_detail(hass): +async def _mock_doorsense_missing_yale_lock_detail(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json") -async def _mock_lock_with_unlatch(hass): +async def _mock_lock_with_unlatch(hass: HomeAssistant) -> LockDetail: return await _mock_lock_from_fixture(hass, "get_lock.online_with_unlatch.json") -def _mock_lock_operation_activity(lock, action, offset): +def _mock_lock_operation_activity( + lock: Lock, action: str, offset: float +) -> LockOperationActivity: return LockOperationActivity( SOURCE_LOCK_OPERATE, { @@ -397,7 +482,9 @@ def _mock_lock_operation_activity(lock, action, offset): ) -def _mock_door_operation_activity(lock, action, offset): +def _mock_door_operation_activity( + lock: Lock, action: str, offset: float +) -> DoorOperationActivity: return DoorOperationActivity( SOURCE_LOCK_OPERATE, { @@ -409,7 +496,7 @@ def _mock_door_operation_activity(lock, action, offset): ) -def _activity_from_dict(activity_dict): +def _activity_from_dict(activity_dict: dict[str, Any]) -> Activity | None: action = activity_dict.get("action") activity_dict["dateTime"] = time.time() * 1000 diff --git a/tests/components/yale/test_config_flow.py b/tests/components/yale/test_config_flow.py index faaa52003217a2..a62aa2d38f903a 100644 --- a/tests/components/yale/test_config_flow.py +++ b/tests/components/yale/test_config_flow.py @@ -1,403 +1,207 @@ -"""Test the Yale config flow.""" +"""Test the yale config flow.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch -from yalexs.authenticator import ValidationResult -from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation +import pytest from homeassistant import config_entries -from homeassistant.components.yale.const import ( - CONF_ACCESS_TOKEN_CACHE_FILE, - CONF_BRAND, - CONF_INSTALL_ID, - CONF_LOGIN_METHOD, - DOMAIN, - VERIFICATION_CODE_KEY, +from homeassistant.components.yale.application_credentials import ( + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, ) -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.components.yale.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .mocks import USER_ID from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator +CLIENT_ID = "1" -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.fixture +def mock_setup_entry() -> Generator[Mock, None, None]: + """Patch setup entry.""" + with patch( + "homeassistant.components.yale.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.mark.usefixtures("client_credentials") +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + jwt: str, + mock_setup_entry: Mock, +) -> None: + """Check full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - return_value=True, - ), - patch( - "homeassistant.components.yale.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "my@email.tld" - assert result2["data"] == { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_INSTALL_ID: None, - CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - with patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=InvalidAuth, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_user_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle an unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" ) - with patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=ValueError("something exploded"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unhandled"} - assert result2["description_placeholders"] == {"error": "something exploded"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": jwt, + "scope": "any", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "user_id": "mock-user-id", + "expires_at": 1697753347, + }, ) - with patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=CannotConnect, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_needs_validate(hass: HomeAssistant) -> None: - """Test we present validation when we need to validate.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + await hass.config_entries.flow.async_configure(result["flow_id"]) - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=RequireValidation, - ), - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", - return_value=True, - ) as mock_send_verification_code, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - - assert len(mock_send_verification_code.mock_calls) == 1 - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] is None - assert result2["step_id"] == "validation" - - # Try with the WRONG verification code give us the form back again - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=RequireValidation, - ), - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", - return_value=ValidationResult.INVALID_VERIFICATION_CODE, - ) as mock_validate_verification_code, - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", - return_value=True, - ) as mock_send_verification_code, - ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {VERIFICATION_CODE_KEY: "incorrect"}, - ) - - # Make sure we do not resend the code again - # so they have a chance to retry - assert len(mock_send_verification_code.mock_calls) == 0 - assert len(mock_validate_verification_code.mock_calls) == 1 - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "invalid_verification_code"} - assert result3["step_id"] == "validation" - - # Try with the CORRECT verification code and we setup - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - return_value=True, - ), - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", - return_value=ValidationResult.VALIDATED, - ) as mock_validate_verification_code, - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", - return_value=True, - ) as mock_send_verification_code, - patch( - "homeassistant.components.yale.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {VERIFICATION_CODE_KEY: "correct"}, - ) - await hass.async_block_till_done() - - assert len(mock_send_verification_code.mock_calls) == 0 - assert len(mock_validate_verification_code.mock_calls) == 1 - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "my@email.tld" - assert result4["data"] == { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_INSTALL_ID: None, - CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", - } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_reauth(hass: HomeAssistant) -> None: - """Test reauthenticate.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - CONF_INSTALL_ID: None, - CONF_TIMEOUT: 10, - CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.unique_id == USER_ID + + +@pytest.mark.usefixtures("client_credentials") +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + reauth_jwt: str, + mock_setup_entry: Mock, +) -> None: + """Test the reauthentication case updates the existing config entry.""" + + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - unique_id="my@email.tld", ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": reauth_jwt, + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "user_id": USER_ID, + "token_type": "Bearer", + "expires_at": 1697753347, + }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - return_value=True, - ), - patch( - "homeassistant.components.yale.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "new-test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: - """Test reauthenticate with 2fa.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - CONF_INSTALL_ID: None, - CONF_TIMEOUT: 10, - CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.unique_id == USER_ID + assert "token" in mock_config_entry.data + # Verify access token is refreshed + assert mock_config_entry.data["token"]["access_token"] == reauth_jwt + + +@pytest.mark.usefixtures("client_credentials") +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + reauth_jwt_wrong_account: str, + jwt: str, + mock_setup_entry: Mock, +) -> None: + """Test the reauthentication aborts, if user tries to reauthenticate with another account.""" + assert mock_config_entry.data["token"]["access_token"] == jwt + + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - unique_id="my@email.tld", ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": reauth_jwt_wrong_account, + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "token_type": "Bearer", + "expires_at": 1697753347, + }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - side_effect=RequireValidation, - ), - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", - return_value=True, - ) as mock_send_verification_code, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "new-test-password", - }, - ) - await hass.async_block_till_done() - - assert len(mock_send_verification_code.mock_calls) == 1 - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] is None - assert result2["step_id"] == "validation" - - # Try with the CORRECT verification code and we setup - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - return_value=True, - ), - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", - return_value=ValidationResult.VALIDATED, - ) as mock_validate_verification_code, - patch( - "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", - return_value=True, - ) as mock_send_verification_code, - patch( - "homeassistant.components.yale.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {VERIFICATION_CODE_KEY: "correct"}, - ) - await hass.async_block_till_done() - - assert len(mock_validate_verification_code.mock_calls) == 1 - assert len(mock_send_verification_code.mock_calls) == 0 - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() -async def test_switching_brands(hass: HomeAssistant) -> None: - """Test brands can be switched by setting up again.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - CONF_INSTALL_ID: None, - CONF_TIMEOUT: 10, - CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", - }, - unique_id="my@email.tld", - ) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.yale.config_flow.YaleGateway.async_authenticate", - return_value=True, - ), - patch( - "homeassistant.components.yale.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_BRAND: "yale_home", - CONF_LOGIN_METHOD: "email", - CONF_USERNAME: "my@email.tld", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 - assert entry.data[CONF_BRAND] == "yale_home" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_invalid_user" + + assert mock_config_entry.unique_id == USER_ID + assert "token" in mock_config_entry.data + # Verify access token is like before + assert mock_config_entry.data["token"]["access_token"] == jwt diff --git a/tests/components/yale/test_gateway.py b/tests/components/yale/test_gateway.py deleted file mode 100644 index 5f4f43a840d805..00000000000000 --- a/tests/components/yale/test_gateway.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The gateway tests for the yale platform.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from yalexs.authenticator_common import AuthenticationState - -from homeassistant.components.yale.const import DOMAIN -from homeassistant.components.yale.gateway import YaleGateway -from homeassistant.core import HomeAssistant - -from .mocks import _mock_get_config, _mock_yale_authentication - - -async def test_refresh_access_token(hass: HomeAssistant) -> None: - """Test token refreshes.""" - await _patched_refresh_access_token(hass, "new_token", 5678) - - -@patch("yalexs.manager.gateway.ApiAsync.async_get_operable_locks") -@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate") -@patch("yalexs.manager.gateway.AuthenticatorAsync.should_refresh") -@patch("yalexs.manager.gateway.AuthenticatorAsync.async_refresh_access_token") -async def _patched_refresh_access_token( - hass, - new_token, - new_token_expire_time, - refresh_access_token_mock, - should_refresh_mock, - authenticate_mock, - async_get_operable_locks_mock, -): - authenticate_mock.side_effect = MagicMock( - return_value=_mock_yale_authentication( - "original_token", 1234, AuthenticationState.AUTHENTICATED - ) - ) - yale_gateway = YaleGateway(Path(hass.config.config_dir), MagicMock()) - mocked_config = _mock_get_config() - await yale_gateway.async_setup(mocked_config[DOMAIN]) - await yale_gateway.async_authenticate() - - should_refresh_mock.return_value = False - await yale_gateway.async_refresh_access_token_if_needed() - refresh_access_token_mock.assert_not_called() - - should_refresh_mock.return_value = True - refresh_access_token_mock.return_value = _mock_yale_authentication( - new_token, new_token_expire_time, AuthenticationState.AUTHENTICATED - ) - await yale_gateway.async_refresh_access_token_if_needed() - refresh_access_token_mock.assert_called() - assert yale_gateway.access_token == new_token - assert yale_gateway.authentication.access_token_expires == new_token_expire_time diff --git a/tests/components/yale/test_init.py b/tests/components/yale/test_init.py index 3a382ecd84c906..e2646224598f44 100644 --- a/tests/components/yale/test_init.py +++ b/tests/components/yale/test_init.py @@ -1,11 +1,10 @@ """The tests for the yale platform.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from aiohttp import ClientResponseError import pytest -from yalexs.authenticator_common import AuthenticationState -from yalexs.exceptions import AugustApiAIOHTTPError +from yalexs.exceptions import InvalidAuth, YaleApiError from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.yale.const import DOMAIN @@ -27,81 +26,49 @@ _create_yale_with_devices, _mock_doorsense_enabled_yale_lock_detail, _mock_doorsense_missing_yale_lock_detail, - _mock_get_config, _mock_inoperative_yale_lock_detail, _mock_lock_with_offline_key, _mock_operative_yale_lock_detail, - _mock_yale_authentication, ) -from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator async def test_yale_api_is_failing(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale api is failing.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", + config_entry = await _create_yale_with_devices( + hass, + authenticate_side_effect=YaleApiError( + "offline", ClientResponseError(None, None, status=500) + ), ) - config_entry.add_to_hass(hass) - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - side_effect=ClientResponseError(None, None, status=500), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_yale_is_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale is offline.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", + config_entry = await _create_yale_with_devices( + hass, authenticate_side_effect=TimeoutError ) - config_entry.add_to_hass(hass) - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - side_effect=TimeoutError, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_yale_late_auth_failure(hass: HomeAssistant) -> None: """Test we can detect a late auth failure.""" - aiohttp_client_response_exception = ClientResponseError(None, None, status=401) - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - side_effect=AugustApiAIOHTTPError( - "This should bubble up as its user consumable", - aiohttp_client_response_exception, + config_entry = await _create_yale_with_devices( + hass, + authenticate_side_effect=InvalidAuth( + "authfailed", ClientResponseError(None, None, status=401) ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + ) assert config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() - assert flows[0]["step_id"] == "reauth_validate" + assert flows[0]["step_id"] == "pick_implementation" async def test_unlock_throws_yale_api_http_error(hass: HomeAssistant) -> None: @@ -110,7 +77,7 @@ async def test_unlock_throws_yale_api_http_error(hass: HomeAssistant) -> None: aiohttp_client_response_exception = ClientResponseError(None, None, status=400) def _unlock_return_activities_side_effect(access_token, device_id): - raise AugustApiAIOHTTPError( + raise YaleApiError( "This should bubble up as its user consumable", aiohttp_client_response_exception, ) @@ -140,7 +107,7 @@ async def test_lock_throws_yale_api_http_error(hass: HomeAssistant) -> None: aiohttp_client_response_exception = ClientResponseError(None, None, status=400) def _lock_return_activities_side_effect(access_token, device_id): - raise AugustApiAIOHTTPError( + raise YaleApiError( "This should bubble up as its user consumable", aiohttp_client_response_exception, ) @@ -205,157 +172,6 @@ async def test_lock_has_doorsense(hass: HomeAssistant) -> None: assert binary_sensor_missing_doorsense_id_name_open is None -async def test_auth_fails(hass: HomeAssistant) -> None: - """Config entry state is SETUP_ERROR when auth fails.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - side_effect=ClientResponseError(None, None, status=401), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - - assert flows[0]["step_id"] == "reauth_validate" - - -async def test_bad_password(hass: HomeAssistant) -> None: - """Config entry state is SETUP_ERROR when the password has been changed.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - return_value=_mock_yale_authentication( - "original_token", 1234, AuthenticationState.BAD_PASSWORD - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - - assert flows[0]["step_id"] == "reauth_validate" - - -async def test_http_failure(hass: HomeAssistant) -> None: - """Config entry state is SETUP_RETRY when yale is offline.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - side_effect=ClientResponseError(None, None, status=500), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_RETRY - - assert hass.config_entries.flow.async_progress() == [] - - -async def test_unknown_auth_state(hass: HomeAssistant) -> None: - """Config entry state is SETUP_ERROR when yale is in an unknown auth state.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - return_value=_mock_yale_authentication("original_token", 1234, None), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - - assert flows[0]["step_id"] == "reauth_validate" - - -async def test_requires_validation_state(hass: HomeAssistant) -> None: - """Config entry state is SETUP_ERROR when yale requires validation.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - return_value=_mock_yale_authentication( - "original_token", 1234, AuthenticationState.REQUIRES_VALIDATION - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - assert len(hass.config_entries.flow.async_progress()) == 1 - assert hass.config_entries.flow.async_progress()[0]["context"]["source"] == "reauth" - - -async def test_unknown_auth_http_401(hass: HomeAssistant) -> None: - """Config entry state is SETUP_ERROR when yale gets an http.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=_mock_get_config()[DOMAIN], - title="Yale yale", - ) - config_entry.add_to_hass(hass) - assert hass.config_entries.flow.async_progress() == [] - - with patch( - "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", - return_value=_mock_yale_authentication("original_token", 1234, None), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - - assert flows[0]["step_id"] == "reauth_validate" - - async def test_load_unload(hass: HomeAssistant) -> None: """Config entry can be unloaded."""