Switch to August OAuth with official API (#151080)

This commit is contained in:
J. Nick Koston
2025-08-23 22:29:53 -05:00
committed by GitHub
parent 3c11f8e50e
commit 03ca164fb3
21 changed files with 897 additions and 1031 deletions

View File

@@ -6,18 +6,21 @@ from pathlib import Path
from typing import cast from typing import cast
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from yalexs.const import Brand
from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from yalexs.manager.gateway import Config as YaleXSConfig from yalexs.manager.gateway import Config as YaleXSConfig
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers import (
config_entry_oauth2_flow,
device_registry as dr,
issue_registry as ir,
)
from .const import DOMAIN, PLATFORMS from .const import DEFAULT_AUGUST_BRAND, DOMAIN, PLATFORMS
from .data import AugustData from .data import AugustData
from .gateway import AugustGateway from .gateway import AugustGateway
from .util import async_create_august_clientsession from .util import async_create_august_clientsession
@@ -25,30 +28,21 @@ from .util import async_create_august_clientsession
type AugustConfigEntry = ConfigEntry[AugustData] type AugustConfigEntry = ConfigEntry[AugustData]
@callback
def _async_create_yale_brand_migration_issue(
hass: HomeAssistant, entry: AugustConfigEntry
) -> None:
"""Create an issue for a brand migration."""
ir.async_create_issue(
hass,
DOMAIN,
"yale_brand_migration",
breaks_in_ha_version="2024.9",
learn_more_url="https://www.home-assistant.io/integrations/yale",
translation_key="yale_brand_migration",
is_fixable=False,
severity=ir.IssueSeverity.CRITICAL,
translation_placeholders={
"migrate_url": "https://my.home-assistant.io/redirect/config_flow_start?domain=yale"
},
)
async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool:
"""Set up August from a config entry.""" """Set up August from a config entry."""
# Check if this is a legacy config entry that needs migration to OAuth
if "auth_implementation" not in entry.data:
# This is a legacy entry using username/password, trigger reauth
raise ConfigEntryAuthFailed("Migration to OAuth required")
session = async_create_august_clientsession(hass) session = async_create_august_clientsession(hass)
august_gateway = AugustGateway(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)
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try: try:
await async_setup_august(hass, entry, august_gateway) await async_setup_august(hass, entry, august_gateway)
except (RequireValidation, InvalidAuth) as err: except (RequireValidation, InvalidAuth) as err:
@@ -76,9 +70,7 @@ async def async_setup_august(
) -> None: ) -> None:
"""Set up the August component.""" """Set up the August component."""
config = cast(YaleXSConfig, entry.data) config = cast(YaleXSConfig, entry.data)
await august_gateway.async_setup(config) await august_gateway.async_setup({**config, "brand": DEFAULT_AUGUST_BRAND})
if august_gateway.api.brand == Brand.YALE_HOME:
_async_create_yale_brand_migration_issue(hass, entry)
await august_gateway.async_authenticate() await august_gateway.async_authenticate()
await august_gateway.async_refresh_access_token_if_needed() await august_gateway.async_refresh_access_token_if_needed()
data = entry.runtime_data = AugustData(hass, august_gateway) data = entry.runtime_data = AugustData(hass, august_gateway)

View File

@@ -0,0 +1,15 @@
"""application_credentials platform for the august integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
OAUTH2_AUTHORIZE = "https://auth.august.com/authorization"
OAUTH2_TOKEN = "https://auth.august.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,
)

View File

@@ -1,284 +1,86 @@
"""Config flow for August integration.""" """Config flow for August integration."""
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
import logging import logging
from pathlib import Path
from typing import Any from typing import Any
import aiohttp import jwt
import voluptuous as vol
from yalexs.authenticator_common import ValidationResult
from yalexs.const import BRANDS_WITHOUT_OAUTH, DEFAULT_BRAND, Brand
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.core import callback
from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_LOGIN_METHOD,
DEFAULT_LOGIN_METHOD,
DOMAIN,
LOGIN_METHODS,
VERIFICATION_CODE_KEY,
)
from .gateway import AugustGateway
from .util import async_create_august_clientsession
# The Yale Home Brand is not supported by the August integration
# anymore and should migrate to the Yale integration
AVAILABLE_BRANDS = BRANDS_WITHOUT_OAUTH.copy()
del AVAILABLE_BRANDS[Brand.YALE_HOME]
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_validate_input( class AugustConfigFlow(
data: dict[str, Any], august_gateway: AugustGateway config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
) -> 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 august_gateway.authenticator is not None
authenticator = august_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 august_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 august_gateway.authenticator.async_send_verification_code()
raise
return {
"title": data.get(CONF_USERNAME),
"data": august_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 AugustConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for August.""" """Handle a config flow for August."""
VERSION = 1 VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None: @property
"""Store an AugustGateway().""" def logger(self) -> logging.Logger:
self._august_gateway: AugustGateway | None = None """Return logger."""
self._aiohttp_session: aiohttp.ClientSession | None = None return _LOGGER
self._user_auth_details: dict[str, Any] = {}
self._needs_reset = True
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(AVAILABLE_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,
)
async def async_step_validation(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle validation (2fa) step."""
if user_input:
if self.source == SOURCE_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) -> AugustGateway:
"""Set up the gateway."""
if self._august_gateway is not None:
return self._august_gateway
self._aiohttp_session = async_create_august_clientsession(self.hass)
self._august_gateway = AugustGateway(
Path(self.hass.config.config_dir), self._aiohttp_session
)
return self._august_gateway
@callback
def _async_shutdown_gateway(self) -> None:
"""Shutdown the gateway."""
if self._aiohttp_session is not None:
self._aiohttp_session.detach()
self._august_gateway = None
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle configuration by re-auth.""" """Handle configuration by re-auth."""
self._user_auth_details = dict(entry_data) return await self.async_step_user()
return await self.async_step_reauth_validate()
async def async_step_reauth_validate( def _async_decode_jwt(self, encoded: str) -> dict[str, Any]:
self, user_input: dict[str, Any] | None = None """Decode JWT token."""
return jwt.decode(
encoded,
"",
verify=False,
options={"verify_signature": False},
algorithms=["HS256"],
)
async def _async_handle_reauth(
self, data: dict, decoded: dict[str, Any], user_id: str
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reauth and validation.""" """Handle reauth flow."""
errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry()
description_placeholders: dict[str, str] = {} assert reauth_entry.unique_id is not None
if user_input is not None: # Check if this is a migration from username (contains @) to user ID
self._user_auth_details.update(user_input) if "@" not in reauth_entry.unique_id:
validate_result = await self._async_auth_or_validate() # This is a normal oauth reauth, enforce ID matching for security
description_placeholders = validate_result.description_placeholders await self.async_set_unique_id(user_id)
if validate_result.validation_required: self._abort_if_unique_id_mismatch(reason="reauth_invalid_user")
return await self.async_step_validation() return self.async_update_reload_and_abort(reauth_entry, data=data)
if not (errors := validate_result.errors):
return await self._async_update_or_create_entry(validate_result.info)
return self.async_show_form( # This is a one-time migration from username to user ID
step_id="reauth_validate", # Only validate if the account has emails
data_schema=vol.Schema( emails: list[str]
{ if emails := decoded.get("email", []):
vol.Required( # Validate that the email matches before allowing migration
CONF_BRAND, email_to_check_lower = reauth_entry.unique_id.casefold()
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), if not any(email.casefold() == email_to_check_lower for email in emails):
): vol.In(BRANDS_WITHOUT_OAUTH), # Email doesn't match - this is a different account
vol.Required(CONF_PASSWORD): str, return self.async_abort(reason="reauth_invalid_user")
}
), # Email matches or no emails on account, update with new unique ID
errors=errors, return self.async_update_reload_and_abort(
description_placeholders=description_placeholders reauth_entry, data=data, unique_id=user_id
| {
CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
},
) )
async def _async_reset_access_token_cache_if_needed( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
self, gateway: AugustGateway, username: str, access_token_cache_file: str | None """Create an entry for the flow."""
) -> None: # Decode JWT once
"""Reset the access token cache if needed.""" access_token = data["token"]["access_token"]
# We need to configure the access token cache file before we setup the gateway decoded = self._async_decode_jwt(access_token)
# since we need to reset it if the brand changes BEFORE we setup the gateway user_id = decoded["userId"]
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: if self.source == SOURCE_REAUTH:
"""Authenticate or validate.""" return await self._async_handle_reauth(data, decoded, user_id)
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] = {} await self.async_set_unique_id(user_id)
info: dict[str, Any] = {} self._abort_if_unique_id_configured()
description_placeholders: dict[str, str] = {} return await super().async_oauth_create_entry(data)
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"])

View File

@@ -1,5 +1,7 @@
"""Constants for August devices.""" """Constants for August devices."""
from yalexs.const import Brand
from homeassistant.const import Platform from homeassistant.const import Platform
DEFAULT_TIMEOUT = 25 DEFAULT_TIMEOUT = 25
@@ -9,6 +11,8 @@ CONF_BRAND = "brand"
CONF_LOGIN_METHOD = "login_method" CONF_LOGIN_METHOD = "login_method"
CONF_INSTALL_ID = "install_id" CONF_INSTALL_ID = "install_id"
DEFAULT_AUGUST_BRAND = Brand.YALE_AUGUST
VERIFICATION_CODE_KEY = "verification_code" VERIFICATION_CODE_KEY = "verification_code"
NOTIFICATION_ID = "august_notification" NOTIFICATION_ID = "august_notification"

View File

@@ -1,30 +1,43 @@
"""Handle August connection setup and authentication.""" """Handle August connection setup and authentication."""
from typing import Any import logging
from pathlib import Path
from yalexs.const import DEFAULT_BRAND from aiohttp import ClientSession
from yalexs.authenticator_common import Authentication, AuthenticationState
from yalexs.manager.gateway import Gateway from yalexs.manager.gateway import Gateway
from homeassistant.const import CONF_USERNAME from homeassistant.helpers import config_entry_oauth2_flow
from .const import ( _LOGGER = logging.getLogger(__name__)
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
)
class AugustGateway(Gateway): class AugustGateway(Gateway):
"""Handle the connection to August.""" """Handle the connection to August."""
def config_entry(self) -> dict[str, Any]: def __init__(
"""Config entry.""" self,
assert self._config is not None config_path: Path,
return { aiohttp_session: ClientSession,
CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND), oauth_session: config_entry_oauth2_flow.OAuth2Session,
CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], ) -> None:
CONF_USERNAME: self._config[CONF_USERNAME], """Init the connection."""
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), super().__init__(config_path, aiohttp_session)
CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, 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

View File

@@ -3,6 +3,7 @@
"name": "August", "name": "August",
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"config_flow": true, "config_flow": true,
"dependencies": ["application_credentials", "cloud"],
"dhcp": [ "dhcp": [
{ {
"hostname": "connect", "hostname": "connect",

View File

@@ -6,42 +6,34 @@
} }
}, },
"config": { "config": {
"error": { "step": {
"unhandled": "Unhandled error: {error}", "pick_implementation": {
"invalid_verification_code": "Invalid verification code", "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "data": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "implementation": "[%key:common::config_flow::data::implementation%]"
},
"data_description": {
"implementation": "[%key:common::config_flow::description::implementation%]"
}
}
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "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%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"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": { "create_entry": {
"validation": { "default": "[%key:common::config_flow::create_entry::authenticated%]"
"title": "Two-factor authentication",
"data": {
"verification_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 August 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::august::config::step::user_validate::data::brand%]",
"password": "[%key:common::config_flow::data::password%]"
},
"title": "Reauthenticate an August account"
}
} }
}, },
"entity": { "entity": {

View File

@@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest
""" """
APPLICATION_CREDENTIALS = [ APPLICATION_CREDENTIALS = [
"august",
"electric_kiwi", "electric_kiwi",
"fitbit", "fitbit",
"geocaching", "geocaching",

View File

@@ -5,6 +5,19 @@ from unittest.mock import patch
import pytest import pytest
from yalexs.manager.ratelimit import _RateLimitChecker from yalexs.manager.ratelimit import _RateLimitChecker
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.august.const import DOMAIN
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"
CLIENT_ID = "1"
@pytest.fixture(name="mock_discovery", autouse=True) @pytest.fixture(name="mock_discovery", autouse=True)
def mock_discovery_fixture(): def mock_discovery_fixture():
@@ -20,3 +33,105 @@ def disable_ratelimit_checks_fixture():
"""Disable rate limit checks.""" """Disable rate limit checks."""
with patch.object(_RateLimitChecker, "register_wakeup"): with patch.object(_RateLimitChecker, "register_wakeup"):
yield yield
@pytest.fixture(name="mock_config_entry")
def mock_config_entry_fixture(jwt: str) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "august",
"token": {
"access_token": jwt,
"scope": "any",
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"user_id": "mock-user-id",
"expires_at": 1697753347,
},
},
unique_id=USER_ID,
)
@pytest.fixture(name="mock_legacy_config_entry")
def mock_legacy_config_entry_fixture() -> MockConfigEntry:
"""Return a legacy config entry without OAuth data."""
return MockConfigEntry(
domain=DOMAIN,
data={
"login_method": "email",
"username": "my@email.tld",
"password": "test-password",
"install_id": None,
"timeout": 10,
"access_token_cache_file": ".my@email.tld.august.conf",
},
unique_id="my@email.tld",
)
@pytest.fixture(name="jwt")
def load_jwt_fixture() -> str:
"""Load Fixture data."""
return load_fixture("jwt", DOMAIN).strip("\n")
@pytest.fixture(name="legacy_jwt")
def load_legacy_jwt_fixture() -> str:
"""Load legacy JWT fixture data."""
return load_fixture("legacy_jwt", DOMAIN).strip("\n")
@pytest.fixture(name="reauth_jwt")
def load_reauth_jwt_fixture() -> str:
"""Load reauth JWT fixture data."""
return load_fixture("reauth_jwt", DOMAIN).strip("\n")
@pytest.fixture(name="migration_jwt")
def load_migration_jwt_fixture() -> str:
"""Load migration JWT fixture data (has email for legacy migration)."""
return load_fixture("migration_jwt", DOMAIN).strip("\n")
@pytest.fixture(name="reauth_jwt_wrong_account")
def load_reauth_jwt_wrong_account_fixture() -> str:
"""Load JWT fixture data for wrong account during reauth."""
# Different userId, no email match
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImRpZmZlcmVudC11c2VyLWlkIiwidkluc3RhbGxJZCI6ZmFsc2UsInZQYXNzd29yZCI6dHJ1ZSwidkVtYWlsIjp0cnVlLCJ2UGhvbmUiOnRydWUsImhhc0luc3RhbGxJZCI6ZmFsc2UsImhhc1Bhc3N3b3JkIjpmYWxzZSwiaGFzRW1haWwiOmZhbHNlLCJoYXNQaG9uZSI6ZmFsc2UsImlzTG9ja2VkT3V0IjpmYWxzZSwiY2FwdGNoYSI6IiIsImVtYWlsIjpbImRpZmZlcmVudEBlbWFpbC50bGQiXSwicGhvbmUiOltdLCJleHBpcmVzQXQiOiIyMDI0LTEyLTE4VDEzOjU0OjA1LjEzNFoiLCJ0ZW1wb3JhcnlBY2NvdW50Q3JlYXRpb25QYXNzd29yZExpbmsiOiIiLCJpYXQiOjE3MjQxNjIwNDUsImV4cCI6MTczNDUzMDA0NSwib2F1dGgiOnsiYXBwX25hbWUiOiJIb21lIEFzc2lzdGFudCIsImNsaWVudF9pZCI6ImIzY2QzZjBiLWZiOTctNGQ2Yy1iZWU5LWFmN2FiMDQ3NThjNyIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vYWNjb3VudC1saW5rLm5hYnVjYXNhLmNvbS9hdXRob3JpemVfY2FsbGJhY2siLCJwYXJ0bmVyX2lkIjoiNjU3OTc0ODgxMDY2Y2E0OGM5OWMwODI2In19.mK9nTAv7glYgtpLIkVF_dsrjrkRKYemdKfKMkgnafCU"
@pytest.fixture(name="client_credentials", autouse=True)
async def mock_client_credentials_fixture(hass: HomeAssistant) -> None:
"""Mock client credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, "2"),
DOMAIN,
)
@pytest.fixture(name="skip_cloud", autouse=True)
def skip_cloud_fixture():
"""Skip setting up cloud.
Cloud already has its own tests for account link.
We do not need to test it here as we only need to test our
usage of the oauth2 helpers.
"""
with patch("homeassistant.components.cloud.async_setup", return_value=True):
yield
@pytest.fixture
def mock_setup_entry():
"""Mock setup entry."""
with patch(
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@@ -0,0 +1 @@
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImE3NmMyNWU1LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZE91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6W10sInBob25lIjpbXSwiZXhwaXJlc0F0IjoiMjAyNC0xMi0xOFQxMzo1NDowNS4xMzRaIiwidGVtcG9yYXJ5QWNjb3VudENyZWF0aW9uUGFzc3dvcmRMaW5rIjoiIiwiaWF0IjoxNzI0MTYyMDQ1LCJleHAiOjE3MzQ1MzAwNDUsIm9hdXRoIjp7ImFwcF9uYW1lIjoiSG9tZSBBc3Npc3RhbnQiLCJjbGllbnRfaWQiOiJiM2NkM2YwYi1mYjk3LTRkNmMtYmVlOS1hZjdhYjA0NzU4YzciLCJyZWRpcmVjdF91cmkiOiJodHRwczovL2FjY291bnQtbGluay5uYWJ1Y2FzYS5jb20vYXV0aG9yaXplX2NhbGxiYWNrIiwicGFydG5lcl9pZCI6IjY1Nzk3NDg4MTA2NmNhNDhjOTljMDgyNiJ9fQ.BdRo-dEr-osbDQGB2XzlI-mIj4gqULtapODt-sj-eA8

View File

@@ -0,0 +1 @@
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImxlZ2FjeS11c2VyLWlkIiwidkluc3RhbGxJZCI6ZmFsc2UsInZQYXNzd29yZCI6dHJ1ZSwidkVtYWlsIjp0cnVlLCJ2UGhvbmUiOnRydWUsImhhc0luc3RhbGxJZCI6ZmFsc2UsImhhc1Bhc3N3b3JkIjpmYWxzZSwiaGFzRW1haWwiOmZhbHNlLCJoYXNQaG9uZSI6ZmFsc2UsImlzTG9ja2VkT3V0IjpmYWxzZSwiY2FwdGNoYSI6IiIsImVtYWlsIjpbIm15QGVtYWlsLnRsZCJdLCJwaG9uZSI6W10sImV4cGlyZXNBdCI6IjIwMjQtMTItMThUMTM6NTQ6MDUuMTM0WiIsInRlbXBvcmFyeUFjY291bnRDcmVhdGlvblBhc3N3b3JkTGluayI6IiIsImlhdCI6MTcyNDE2MjA0NSwiZXhwIjoxNzM0NTMwMDQ1LCJvYXV0aCI6eyJhcHBfbmFtZSI6IkhvbWUgQXNzaXN0YW50IiwiY2xpZW50X2lkIjoiYjNjZDNmMGItZmI5Ny00ZDZjLWJlZTktYWY3YWIwNDc1OGM3IiwicmVkaXJlY3RfdXJpIjoiaHR0cHM6Ly9hY2NvdW50LWxpbmsubmFidWNhc2EuY29tL2F1dGhvcml6ZV9jYWxsYmFjayIsInBhcnRuZXJfaWQiOiI2NTc5NzQ4ODEwNjZjYTQ4Yzk5YzA4MjYifX0.BdRo-dEr-osbDQGB2XzlI-mIj4gqULtapODt-sj-eA8

View File

@@ -0,0 +1 @@
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImE3NmMyNWU1LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZE91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6WyJteUBlbWFpbC50bGQiXSwicGhvbmUiOltdLCJleHBpcmVzQXQiOiIyMDI0LTEyLTE4VDEzOjU0OjA1LjEzNFoiLCJ0ZW1wb3JhcnlBY2NvdW50Q3JlYXRpb25QYXNzd29yZExpbmsiOiIiLCJpYXQiOjE3MjQxNjIwNDUsImV4cCI6MTczNDUzMDA0NSwib2F1dGgiOnsiYXBwX25hbWUiOiJIb21lIEFzc2lzdGFudCIsImNsaWVudF9pZCI6ImIzY2QzZjBiLWZiOTctNGQ2Yy1iZWU5LWFmN2FiMDQ3NThjNyIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vYWNjb3VudC1saW5rLm5hYnVjYXNhLmNvbS9hdXRob3JpemVfY2FsbGJhY2siLCJwYXJ0bmVyX2lkIjoiNjU3OTc0ODgxMDY2Y2E0OGM5OWMwODI2In19.migration-token

View File

@@ -0,0 +1 @@
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpbnN0YWxsSWQiOiIiLCJyZWdpb24iOiJpcmVsYW5kLXByb2QtYXdzIiwiYXBwbGljYXRpb25JZCI6IiIsInVzZXJJZCI6ImE3NmMyNWU1LTQ5YWEtNGMxNC1jZDBjLTQ4YTY5MzFlMjA4MSIsInZJbnN0YWxsSWQiOmZhbHNlLCJ2UGFzc3dvcmQiOnRydWUsInZFbWFpbCI6dHJ1ZSwidlBob25lIjp0cnVlLCJoYXNJbnN0YWxsSWQiOmZhbHNlLCJoYXNQYXNzd29yZCI6ZmFsc2UsImhhc0VtYWlsIjpmYWxzZSwiaGFzUGhvbmUiOmZhbHNlLCJpc0xvY2tlZG91dCI6ZmFsc2UsImNhcHRjaGEiOiIiLCJlbWFpbCI6WyJteUBlbWFpbC50bGQiXSwicGhvbmUiOltdLCJleHBpcmVzQXQiOiIyMDI0LTEyLTE4VDEzOjU0OjA1LjEzNFoiLCJ0ZW1wb3JhcnlBY2NvdW50Q3JlYXRpb25QYXNzd29yZExpbmsiOiIiLCJpYXQiOjE3MjQxNjIwNDUsImV4cCI6MTczNDUzMDA0NSwib2F1dGgiOnsiYXBwX25hbWUiOiJIb21lIEFzc2lzdGFudCIsImNsaWVudF9pZCI6ImIzY2QzZjBiLWZiOTctNGQ2Yy1iZWU5LWFmN2FiMDQ3NThjNyIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vYWNjb3VudC1saW5rLm5hYnVjYXNhLmNvbS9hdXRob3JpemVfY2FsbGJhY2siLCJwYXJ0bmVyX2lkIjoiNjU3OTc0ODgxMDY2Y2E0OGM5OWMwODI2In19.reauth-updated-token

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from contextlib import contextmanager
import json import json
import os import os
import time import time
@@ -26,98 +27,184 @@ from yalexs.activity import (
DoorOperationActivity, DoorOperationActivity,
LockOperationActivity, LockOperationActivity,
) )
from yalexs.authenticator_common import AuthenticationState from yalexs.api_async import ApiAsync
from yalexs.authenticator_common import Authentication, AuthenticationState
from yalexs.const import Brand from yalexs.const import Brand
from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.lock import Lock, LockDetail from yalexs.lock import Lock, LockDetail
from yalexs.manager.ratelimit import _RateLimitChecker
from yalexs.pubnub_async import AugustPubNub from yalexs.pubnub_async import AugustPubNub
from homeassistant.components.august.const import CONF_BRAND, CONF_LOGIN_METHOD, DOMAIN from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.august.const import DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture from tests.common import MockConfigEntry, load_fixture
USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081"
def _mock_get_config(brand: Brand = Brand.AUGUST):
def _mock_get_config(
brand: Brand = Brand.YALE_AUGUST, jwt: str | None = None
) -> dict[str, Any]:
"""Return a default august config.""" """Return a default august config."""
return { return {
DOMAIN: { DOMAIN: {
CONF_LOGIN_METHOD: "email", "auth_implementation": "august",
CONF_USERNAME: "mocked_username", "token": {
CONF_PASSWORD: "mocked_password", "access_token": jwt or "access_token",
CONF_BRAND: brand, "expires_in": 1,
"refresh_token": "refresh_token",
"expires_at": time.time() + 3600,
"service": "august",
},
} }
} }
def _mock_authenticator(auth_state): def _mock_authenticator(auth_state: AuthenticationState) -> Authentication:
"""Mock an august authenticator.""" """Mock an august authenticator."""
authenticator = MagicMock() authenticator = MagicMock()
type(authenticator).state = PropertyMock(return_value=auth_state) type(authenticator).state = PropertyMock(return_value=auth_state)
return authenticator return authenticator
def _timetoken(): def _timetoken() -> str:
return str(time.time_ns())[:-2] return str(time.time_ns())[:-2]
@patch("yalexs.manager.gateway.ApiAsync") async def mock_august_config_entry(
@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate") hass: HomeAssistant,
async def _mock_setup_august(
hass: HomeAssistant, api_instance, pubnub_mock, authenticate_mock, api_mock, brand
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up august integration.""" """Mock august config entry and client credentials."""
authenticate_mock.side_effect = MagicMock( entry = mock_config_entry()
return_value=_mock_august_authentication(
"original_token", 1234, AuthenticationState.AUTHENTICATED
)
)
api_mock.return_value = api_instance
entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config(brand)[DOMAIN],
options={},
)
entry.add_to_hass(hass) 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(jwt=jwt)[DOMAIN],
options={},
unique_id=USER_ID,
)
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_august_setup():
"""Patch august setup process."""
with ( with (
patch("yalexs.manager.gateway.ApiAsync") as api_mock,
patch.object(_RateLimitChecker, "register_wakeup") as authenticate_mock,
patch(
"homeassistant.components.august.config_entry_oauth2_flow.async_get_config_entry_implementation"
),
):
yield api_mock, authenticate_mock
async def _mock_setup_august(
hass: HomeAssistant,
api_instance: ApiAsync,
pubnub_mock: AugustPubNub,
authenticate_side_effect: MagicMock,
) -> ConfigEntry:
"""Set up august integration."""
entry = await mock_august_config_entry(hass)
with (
patch_august_setup() as patched_setup,
patch.object(pubnub_mock, "run"), patch.object(pubnub_mock, "run"),
patch("yalexs.manager.data.AugustPubNub", return_value=pubnub_mock), patch("yalexs.manager.data.AugustPubNub", return_value=pubnub_mock),
): ):
assert await hass.config_entries.async_setup(entry.entry_id) api_mock, authenticate_mock = patched_setup
authenticate_mock.side_effect = authenticate_side_effect
api_mock.return_value = api_instance
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
return entry return entry
async def _create_august_with_devices( async def _create_august_with_devices(
hass: HomeAssistant, hass: HomeAssistant,
devices: Iterable[LockDetail | DoorbellDetail], devices: Iterable[LockDetail | DoorbellDetail] | None = None,
api_call_side_effects: dict[str, Any] | None = None, api_call_side_effects: dict[str, Any] | None = None,
activities: list[Any] | None = None, activities: list[Any] | None = None,
pubnub: AugustPubNub | None = None, pubnub: AugustPubNub | None = None,
brand: Brand = Brand.AUGUST, brand: Brand = Brand.YALE_AUGUST,
) -> ConfigEntry: authenticate_side_effect: MagicMock | None = None,
entry, _ = await _create_august_api_with_devices( ) -> tuple[ConfigEntry, AugustPubNub]:
hass, devices, api_call_side_effects, activities, pubnub, brand entry, _, pubnub_instance = await _create_august_api_with_devices(
hass,
devices,
api_call_side_effects,
activities,
pubnub,
brand,
authenticate_side_effect,
) )
return entry return entry, pubnub_instance
async def _create_august_api_with_devices( async def _create_august_api_with_devices(
hass: HomeAssistant, hass: HomeAssistant,
devices: Iterable[LockDetail | DoorbellDetail], devices: Iterable[LockDetail | DoorbellDetail] | None = None,
api_call_side_effects: dict[str, Any] | None = None, api_call_side_effects: dict[str, Any] | None = None,
activities: list[Any] | None = None, activities: dict[str, Any] | None = None,
pubnub: AugustPubNub | None = None, pubnub: AugustPubNub | None = None,
brand: Brand = Brand.AUGUST, brand: Brand = Brand.YALE_AUGUST,
) -> tuple[MockConfigEntry, MagicMock]: authenticate_side_effect: MagicMock | None = None,
) -> tuple[ConfigEntry, ApiAsync, AugustPubNub]:
if api_call_side_effects is None: if api_call_side_effects is None:
api_call_side_effects = {} 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)
if pubnub is None: if pubnub is None:
pubnub = AugustPubNub() pubnub = AugustPubNub()
pubnub.run = AsyncMock()
entry = await _mock_setup_august(
hass,
api_instance,
pubnub,
authenticate_side_effect=authenticate_side_effect,
)
return entry, api_instance, pubnub
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": []} device_data = {"doorbells": [], "locks": []}
for device in devices: for device in devices or ():
if isinstance(device, LockDetail): if isinstance(device, LockDetail):
device_data["locks"].append( device_data["locks"].append(
{"base": _mock_august_lock(device.device_id), "detail": device} {"base": _mock_august_lock(device.device_id), "detail": device}
@@ -127,7 +214,7 @@ async def _create_august_api_with_devices(
{ {
"base": _mock_august_doorbell( "base": _mock_august_doorbell(
deviceid=device.device_id, deviceid=device.device_id,
brand=device._data.get("brand", Brand.AUGUST), brand=device._data.get("brand", Brand.YALE_AUGUST),
), ),
"detail": device, "detail": device,
} }
@@ -200,24 +287,12 @@ async def _create_august_api_with_devices(
"async_unlatch_return_activities", unlock_return_activities_side_effect "async_unlatch_return_activities", unlock_return_activities_side_effect
) )
api_instance, entry = await _mock_setup_august_with_api_side_effects(
hass, api_call_side_effects, pubnub, brand
)
if device_data["locks"]: async def make_mock_api(
# 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_august_with_api_side_effects(
hass: HomeAssistant,
api_call_side_effects: dict[str, Any], api_call_side_effects: dict[str, Any],
pubnub: AugustPubNub, brand: Brand = Brand.YALE_AUGUST,
brand: Brand = Brand.AUGUST, ) -> ApiAsync:
): """Make a mock ApiAsync instance."""
api_instance = MagicMock(name="Api", brand=brand) api_instance = MagicMock(name="Api", brand=brand)
if api_call_side_effects["get_lock_detail"]: if api_call_side_effects["get_lock_detail"]:
@@ -267,12 +342,12 @@ async def _mock_setup_august_with_api_side_effects(
api_instance.async_unlatch_async = AsyncMock() api_instance.async_unlatch_async = AsyncMock()
api_instance.async_unlatch = AsyncMock() api_instance.async_unlatch = AsyncMock()
return api_instance, await _mock_setup_august( return api_instance
hass, api_instance, pubnub, brand=brand
)
def _mock_august_authentication(token_text, token_timestamp, state): def _mock_august_authentication(
token_text: str, token_timestamp: float, state: AuthenticationState
) -> Authentication:
authentication = MagicMock(name="yalexs.authentication") authentication = MagicMock(name="yalexs.authentication")
type(authentication).state = PropertyMock(return_value=state) type(authentication).state = PropertyMock(return_value=state)
type(authentication).access_token = PropertyMock(return_value=token_text) type(authentication).access_token = PropertyMock(return_value=token_text)
@@ -282,13 +357,15 @@ def _mock_august_authentication(token_text, token_timestamp, state):
return authentication return authentication
def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"): def _mock_august_lock(
lockid: str = "mocklockid1", houseid: str = "mockhouseid1"
) -> Lock:
return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid)) return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid))
def _mock_august_doorbell( def _mock_august_doorbell(
deviceid="mockdeviceid1", houseid="mockhouseid1", brand=Brand.AUGUST deviceid="mockdeviceid1", houseid="mockhouseid1", brand=Brand.YALE_AUGUST
): ) -> Doorbell:
return Doorbell( return Doorbell(
deviceid, deviceid,
_mock_august_doorbell_data(deviceid=deviceid, houseid=houseid, brand=brand), _mock_august_doorbell_data(deviceid=deviceid, houseid=houseid, brand=brand),
@@ -296,8 +373,10 @@ def _mock_august_doorbell(
def _mock_august_doorbell_data( def _mock_august_doorbell_data(
deviceid="mockdeviceid1", houseid="mockhouseid1", brand=Brand.AUGUST deviceid: str = "mockdeviceid1",
): houseid: str = "mockhouseid1",
brand: Brand = Brand.YALE_AUGUST,
) -> dict[str, Any]:
return { return {
"_id": deviceid, "_id": deviceid,
"DeviceID": deviceid, "DeviceID": deviceid,
@@ -317,7 +396,9 @@ def _mock_august_doorbell_data(
} }
def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): def _mock_august_lock_data(
lockid: str = "mocklockid1", houseid: str = "mockhouseid1"
) -> dict[str, Any]:
return { return {
"_id": lockid, "_id": lockid,
"LockID": lockid, "LockID": lockid,
@@ -366,12 +447,12 @@ async def _mock_lock_from_fixture(hass: HomeAssistant, path: str) -> LockDetail:
return LockDetail(json_dict) return LockDetail(json_dict)
async def _mock_doorbell_from_fixture(hass: HomeAssistant, path: str) -> DoorbellDetail: async def _mock_doorbell_from_fixture(hass: HomeAssistant, path: str) -> LockDetail:
json_dict = await _load_json_fixture(hass, path) json_dict = await _load_json_fixture(hass, path)
return DoorbellDetail(json_dict) return DoorbellDetail(json_dict)
async def _load_json_fixture(hass: HomeAssistant, path: str) -> Any: async def _load_json_fixture(hass: HomeAssistant, path: str) -> dict[str, Any]:
fixture = await hass.async_add_executor_job( fixture = await hass.async_add_executor_job(
load_fixture, os.path.join("august", path) load_fixture, os.path.join("august", path)
) )
@@ -390,7 +471,9 @@ async def _mock_lock_with_unlatch(hass: HomeAssistant) -> LockDetail:
return await _mock_lock_from_fixture(hass, "get_lock.online_with_unlatch.json") 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( return LockOperationActivity(
SOURCE_LOCK_OPERATE, SOURCE_LOCK_OPERATE,
{ {
@@ -402,7 +485,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( return DoorOperationActivity(
SOURCE_LOCK_OPERATE, SOURCE_LOCK_OPERATE,
{ {

View File

@@ -249,7 +249,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None:
pubnub = AugustPubNub() pubnub = AugustPubNub()
activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json")
config_entry = await _create_august_with_devices( config_entry, _ = await _create_august_with_devices(
hass, [lock_one], activities=activities, pubnub=pubnub hass, [lock_one], activities=activities, pubnub=pubnub
) )
states = hass.states states = hass.states

View File

@@ -12,7 +12,7 @@ async def test_wake_lock(hass: HomeAssistant) -> None:
lock_one = await _mock_lock_from_fixture( lock_one = await _mock_lock_from_fixture(
hass, "get_lock.online_with_doorsense.json" hass, "get_lock.online_with_doorsense.json"
) )
_, api_instance = await _create_august_api_with_devices(hass, [lock_one]) _, api_instance, _ = await _create_august_api_with_devices(hass, [lock_one])
entity_id = "button.online_with_doorsense_name_wake" entity_id = "button.online_with_doorsense_name_wake"
binary_sensor_online_with_doorsense_name = hass.states.get(entity_id) binary_sensor_online_with_doorsense_name = hass.states.get(entity_id)
assert binary_sensor_online_with_doorsense_name is not None assert binary_sensor_online_with_doorsense_name is not None

View File

@@ -1,399 +1,456 @@
"""Test the August config flow.""" """Test the August config flow."""
from unittest.mock import patch from collections.abc import Generator
from unittest.mock import ANY, Mock, patch
from yalexs.authenticator_common import ValidationResult import pytest
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from homeassistant.components.august.const import ( from homeassistant.components.august.application_credentials import (
CONF_ACCESS_TOKEN_CACHE_FILE, OAUTH2_AUTHORIZE,
CONF_BRAND, OAUTH2_TOKEN,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DOMAIN,
VERIFICATION_CODE_KEY,
) )
from homeassistant.components.august.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
CLIENT_ID = "1"
USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081"
async def test_form(hass: HomeAssistant) -> None: @pytest.fixture
"""Test we get the form.""" def mock_setup_entry() -> Generator[Mock]:
"""Patch setup entry."""
with patch(
"homeassistant.components.august.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")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
jwt: str,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] is FlowResultType.FORM state = config_entry_oauth2_flow._encode_jwt(
assert result["errors"] == {} hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
with ( assert result["url"] == (
patch( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", "&redirect_uri=https://example.com/auth/external/callback"
return_value=True, f"&state={state}"
), )
patch(
"homeassistant.components.august.async_setup_entry", client = await hass_client_no_auth()
return_value=True, resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
) as mock_setup_entry, assert resp.status == 200
): assert resp.headers["content-type"] == "text/html; charset=utf-8"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], aioclient_mock.clear_requests()
{ aioclient_mock.post(
CONF_BRAND: "august", OAUTH2_TOKEN,
CONF_LOGIN_METHOD: "email", json={
CONF_USERNAME: "my@email.tld", "access_token": jwt,
CONF_PASSWORD: "test-password", "scope": "any",
}, "expires_in": 86399,
) "refresh_token": "mock-refresh-token",
await hass.async_block_till_done() "user_id": "mock-user-id",
"expires_at": 1697753347,
},
)
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.unique_id == USER_ID
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "my@email.tld" assert result2["result"].unique_id == USER_ID
assert result2["data"] == { assert entry.data == {
CONF_BRAND: "august", "auth_implementation": "august",
CONF_LOGIN_METHOD: "email", "token": {
CONF_USERNAME: "my@email.tld", "access_token": jwt,
CONF_INSTALL_ID: None, "expires_at": ANY,
CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", "expires_in": ANY,
"refresh_token": "mock-refresh-token",
"scope": "any",
"user_id": "mock-user-id",
},
} }
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("client_credentials")
"""Test we handle invalid auth.""" @pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_full_flow_already_exists(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
jwt: str,
mock_config_entry: MockConfigEntry,
) -> None:
"""Check full flow for a user that already exists."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
state = config_entry_oauth2_flow._encode_jwt(
with patch( hass,
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", {
side_effect=InvalidAuth, "flow_id": result["flow_id"],
): "redirect_uri": "https://example.com/auth/external/callback",
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BRAND: "august",
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": SOURCE_USER}
)
with patch(
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
side_effect=ValueError("something exploded"),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BRAND: "august",
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": SOURCE_USER}
)
with patch(
"homeassistant.components.august.config_flow.AugustGateway.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": SOURCE_USER}
)
with (
patch(
"homeassistant.components.august.config_flow.AugustGateway.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.august.config_flow.AugustGateway.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.august.config_flow.AugustGateway.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.august.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: "august",
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_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",
}, },
unique_id="my@email.tld",
) )
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass) assert result["url"] == (
assert result["type"] is FlowResultType.FORM f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
assert result["errors"] == {} "&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
with ( client = await hass_client_no_auth()
patch( resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", assert resp.status == 200
return_value=True, assert resp.headers["content-type"] == "text/html; charset=utf-8"
),
patch(
"homeassistant.components.august.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()
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,
},
)
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result2["type"] is FlowResultType.ABORT assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful" assert result2["reason"] == "already_configured"
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("client_credentials")
"""Test reauthenticate with 2fa.""" @pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
reauth_jwt: str,
) -> None:
"""Test the reauthentication case updates the existing config entry."""
entry = MockConfigEntry( mock_config_entry.add_to_hass(hass)
domain=DOMAIN,
data={ mock_config_entry.async_start_reauth(hass)
CONF_LOGIN_METHOD: "email", await hass.async_block_till_done()
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password", flows = hass.config_entries.flow.async_progress()
CONF_INSTALL_ID: None, assert len(flows) == 1
CONF_TIMEOUT: 10, result = flows[0]
CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", 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) 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"
result = await entry.start_reauth_flow(hass) aioclient_mock.post(
assert result["type"] is FlowResultType.FORM OAUTH2_TOKEN,
assert result["errors"] == {} json={
"access_token": reauth_jwt,
with ( "expires_in": 86399,
patch( "refresh_token": "mock-refresh-token",
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", "user_id": USER_ID,
side_effect=RequireValidation, "token_type": "Bearer",
), "expires_at": 1697753347,
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.august.config_flow.AugustGateway.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.august.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
async def test_switching_brands(hass: HomeAssistant) -> None:
"""Test brands can be switched by setting up again."""
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( result = await hass.config_entries.flow.async_configure(result["flow_id"])
DOMAIN, context={"source": SOURCE_USER} 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")
@pytest.mark.usefixtures("mock_setup_entry")
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,
) -> 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",
},
) )
assert result["type"] is FlowResultType.FORM client = await hass_client_no_auth()
assert result["errors"] == {} 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"
with ( aioclient_mock.post(
patch( OAUTH2_TOKEN,
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", json={
return_value=True, "access_token": reauth_jwt_wrong_account,
), "expires_in": 86399,
patch( "refresh_token": "mock-refresh-token",
"homeassistant.components.august.async_setup_entry", "token_type": "Bearer",
return_value=True, "expires_at": 1697753347,
) as mock_setup_entry, },
): )
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BRAND: "yale_access",
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 result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result2["reason"] == "reauth_successful" await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
assert entry.data[CONF_BRAND] == "yale_access" assert len(hass.config_entries.async_entries(DOMAIN)) == 1
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
@pytest.mark.usefixtures("client_credentials")
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_legacy_migration_with_email_match(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_legacy_config_entry: MockConfigEntry,
migration_jwt: str,
) -> None:
"""Test migration from legacy username/password config to OAuth with email validation."""
mock_legacy_config_entry.add_to_hass(hass)
# Start reauth flow (triggered by ConfigEntryAuthFailed in async_setup_entry)
mock_legacy_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",
},
)
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": migration_jwt, # JWT with email: ["my@email.tld"]
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"user_id": USER_ID,
"token_type": "Bearer",
"expires_at": 1697753347,
},
)
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"
# Verify the entry was updated with new unique_id and OAuth data
assert mock_legacy_config_entry.unique_id == USER_ID # Updated from email to userId
assert "token" in mock_legacy_config_entry.data
assert mock_legacy_config_entry.data["auth_implementation"] == "august"
assert mock_legacy_config_entry.data["token"]["access_token"] == migration_jwt
@pytest.mark.usefixtures("client_credentials")
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_legacy_migration_wrong_email(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_legacy_config_entry: MockConfigEntry,
reauth_jwt_wrong_account: str,
) -> None:
"""Test migration from legacy config fails when email doesn't match."""
mock_legacy_config_entry.add_to_hass(hass)
# Start reauth flow
mock_legacy_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",
},
)
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, # JWT with email: ["different@email.tld"]
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"token_type": "Bearer",
"expires_at": 1697753347,
},
)
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_invalid_user"
# Verify the entry was NOT updated
assert mock_legacy_config_entry.unique_id == "my@email.tld" # Still email
assert "token" not in mock_legacy_config_entry.data # Still legacy data
@pytest.mark.usefixtures("client_credentials")
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_legacy_migration_no_email_in_jwt(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_legacy_config_entry: MockConfigEntry,
jwt: str, # JWT with empty email array
) -> None:
"""Test migration from legacy config succeeds when JWT has no email (can't validate)."""
mock_legacy_config_entry.add_to_hass(hass)
# Start reauth flow
mock_legacy_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",
},
)
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": jwt, # JWT with email: []
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"user_id": USER_ID,
"token_type": "Bearer",
"expires_at": 1697753347,
},
)
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"
# Verify the entry was updated (allowed because no email to validate)
assert mock_legacy_config_entry.unique_id == USER_ID # Updated from email to userId
assert "token" in mock_legacy_config_entry.data
assert mock_legacy_config_entry.data["auth_implementation"] == "august"
assert mock_legacy_config_entry.data["token"]["access_token"] == jwt

View File

@@ -25,7 +25,7 @@ async def test_diagnostics(
) )
doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
entry, _ = await _create_august_api_with_devices(hass, [lock_one, doorbell_one]) entry, _, _ = await _create_august_api_with_devices(hass, [lock_one, doorbell_one])
diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry)
assert diag == snapshot assert diag == snapshot

View File

@@ -1,54 +0,0 @@
"""The gateway tests for the august platform."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from yalexs.authenticator_common import AuthenticationState
from homeassistant.components.august.const import DOMAIN
from homeassistant.components.august.gateway import AugustGateway
from homeassistant.core import HomeAssistant
from .mocks import _mock_august_authentication, _mock_get_config
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: HomeAssistant,
new_token: str,
new_token_expire_time: int,
refresh_access_token_mock,
should_refresh_mock,
authenticate_mock,
async_get_operable_locks_mock,
) -> None:
authenticate_mock.side_effect = MagicMock(
return_value=_mock_august_authentication(
"original_token", 1234, AuthenticationState.AUTHENTICATED
)
)
august_gateway = AugustGateway(Path(hass.config.config_dir), MagicMock())
mocked_config = _mock_get_config()
await august_gateway.async_setup(mocked_config[DOMAIN])
await august_gateway.async_authenticate()
should_refresh_mock.return_value = False
await august_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_august_authentication(
new_token, new_token_expire_time, AuthenticationState.AUTHENTICATED
)
await august_gateway.async_refresh_access_token_if_needed()
refresh_access_token_mock.assert_called()
assert await august_gateway.async_get_access_token() == new_token
assert august_gateway.authentication.access_token_expires == new_token_expire_time

View File

@@ -1,12 +1,11 @@
"""The tests for the august platform.""" """The tests for the august platform."""
from unittest.mock import Mock, patch from unittest.mock import Mock
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
import pytest import pytest
from yalexs.authenticator_common import AuthenticationState
from yalexs.const import Brand from yalexs.const import Brand
from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.exceptions import AugustApiAIOHTTPError, InvalidAuth
from homeassistant.components.august.const import DOMAIN from homeassistant.components.august.const import DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState
@@ -29,10 +28,8 @@ from homeassistant.setup import async_setup_component
from .mocks import ( from .mocks import (
_create_august_with_devices, _create_august_with_devices,
_mock_august_authentication,
_mock_doorsense_enabled_august_lock_detail, _mock_doorsense_enabled_august_lock_detail,
_mock_doorsense_missing_august_lock_detail, _mock_doorsense_missing_august_lock_detail,
_mock_get_config,
_mock_inoperative_august_lock_detail, _mock_inoperative_august_lock_detail,
_mock_lock_with_offline_key, _mock_lock_with_offline_key,
_mock_operative_august_lock_detail, _mock_operative_august_lock_detail,
@@ -44,68 +41,36 @@ from tests.typing import WebSocketGenerator
async def test_august_api_is_failing(hass: HomeAssistant) -> None: async def test_august_api_is_failing(hass: HomeAssistant) -> None:
"""Config entry state is SETUP_RETRY when august api is failing.""" """Config entry state is SETUP_RETRY when august api is failing."""
config_entry, _ = await _create_august_with_devices(
config_entry = MockConfigEntry( hass,
domain=DOMAIN, authenticate_side_effect=AugustApiAIOHTTPError(
data=_mock_get_config()[DOMAIN], "offline", ClientResponseError(None, None, status=500)
title="August august", ),
) )
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 assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_august_is_offline(hass: HomeAssistant) -> None: async def test_august_is_offline(hass: HomeAssistant) -> None:
"""Config entry state is SETUP_RETRY when august is offline.""" """Config entry state is SETUP_RETRY when august is offline."""
config_entry, _ = await _create_august_with_devices(
config_entry = MockConfigEntry( hass, authenticate_side_effect=TimeoutError
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
) )
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 assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_august_late_auth_failure(hass: HomeAssistant) -> None: async def test_august_late_auth_failure(hass: HomeAssistant) -> None:
"""Test we can detect a late auth failure.""" """Test we can detect a late auth failure."""
aiohttp_client_response_exception = ClientResponseError(None, None, status=401) config_entry, _ = await _create_august_with_devices(
config_entry = MockConfigEntry( hass,
domain=DOMAIN, authenticate_side_effect=InvalidAuth(
data=_mock_get_config()[DOMAIN], "authfailed", ClientResponseError(None, None, status=401)
title="August august",
)
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,
), ),
): )
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 config_entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress() 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_august_api_http_error(hass: HomeAssistant) -> None: async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None:
@@ -210,163 +175,12 @@ async def test_lock_has_doorsense(hass: HomeAssistant) -> None:
assert binary_sensor_missing_doorsense_id_name_open is 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="August august",
)
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="August august",
)
config_entry.add_to_hass(hass)
assert hass.config_entries.flow.async_progress() == []
with patch(
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_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 august is offline."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
)
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 august is in an unknown auth state."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
)
config_entry.add_to_hass(hass)
assert hass.config_entries.flow.async_progress() == []
with patch(
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_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 august requires validation."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
)
config_entry.add_to_hass(hass)
assert hass.config_entries.flow.async_progress() == []
with patch(
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_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 august gets an http."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
)
config_entry.add_to_hass(hass)
assert hass.config_entries.flow.async_progress() == []
with patch(
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
return_value=_mock_august_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: async def test_load_unload(hass: HomeAssistant) -> None:
"""Config entry can be unloaded.""" """Config entry can be unloaded."""
august_operative_lock = await _mock_operative_august_lock_detail(hass) august_operative_lock = await _mock_operative_august_lock_detail(hass)
august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass) august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass)
config_entry = await _create_august_with_devices( config_entry, _ = await _create_august_with_devices(
hass, [august_operative_lock, august_inoperative_lock] hass, [august_operative_lock, august_inoperative_lock]
) )
@@ -385,7 +199,7 @@ async def test_load_triggers_ble_discovery(
august_lock_with_key = await _mock_lock_with_offline_key(hass) august_lock_with_key = await _mock_lock_with_offline_key(hass)
august_lock_without_key = await _mock_operative_august_lock_detail(hass) august_lock_without_key = await _mock_operative_august_lock_detail(hass)
config_entry = await _create_august_with_devices( config_entry, _ = await _create_august_with_devices(
hass, [august_lock_with_key, august_lock_without_key] hass, [august_lock_with_key, august_lock_without_key]
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -410,7 +224,7 @@ async def test_device_remove_devices(
"""Test we can only remove a device that no longer exists.""" """Test we can only remove a device that no longer exists."""
assert await async_setup_component(hass, "config", {}) assert await async_setup_component(hass, "config", {})
august_operative_lock = await _mock_operative_august_lock_detail(hass) august_operative_lock = await _mock_operative_august_lock_detail(hass)
config_entry = await _create_august_with_devices(hass, [august_operative_lock]) config_entry, _ = await _create_august_with_devices(hass, [august_operative_lock])
entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"]
device_entry = device_registry.async_get(entity.device_id) device_entry = device_registry.async_get(entity.device_id)
@@ -427,21 +241,46 @@ async def test_device_remove_devices(
async def test_brand_migration_issue(hass: HomeAssistant) -> None: async def test_brand_migration_issue(hass: HomeAssistant) -> None:
"""Test creating and removing the brand migration issue.""" """Test removing the brand migration issue."""
august_operative_lock = await _mock_operative_august_lock_detail(hass) august_operative_lock = await _mock_operative_august_lock_detail(hass)
config_entry = await _create_august_with_devices( config_entry, _ = await _create_august_with_devices(
hass, [august_operative_lock], brand=Brand.YALE_HOME hass, [august_operative_lock], brand=Brand.YALE_HOME
) )
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
issue_reg = ir.async_get(hass) issue_reg = ir.async_get(hass)
issue_entry = issue_reg.async_get_issue(DOMAIN, "yale_brand_migration")
assert issue_entry
assert issue_entry.severity == ir.IssueSeverity.CRITICAL
assert issue_entry.translation_placeholders == {
"migrate_url": "https://my.home-assistant.io/redirect/config_flow_start?domain=yale"
}
await hass.config_entries.async_remove(config_entry.entry_id) await hass.config_entries.async_remove(config_entry.entry_id)
assert not issue_reg.async_get_issue(DOMAIN, "yale_brand_migration") assert not issue_reg.async_get_issue(DOMAIN, "yale_brand_migration")
async def test_oauth_migration_on_legacy_entry(hass: HomeAssistant) -> None:
"""Test that legacy config entry triggers OAuth migration."""
# Create a legacy config entry without auth_implementation
legacy_entry = MockConfigEntry(
domain=DOMAIN,
data={
"login_method": "email",
"username": "test@example.com",
"password": "test-password",
"install_id": None,
"timeout": 10,
"access_token_cache_file": ".test@example.com.august.conf",
},
unique_id="test@example.com",
)
legacy_entry.add_to_hass(hass)
# Try to setup the entry - should fail with auth error and trigger reauth
await hass.config_entries.async_setup(legacy_entry.entry_id)
await hass.async_block_till_done()
# Entry should be in setup_error state
assert legacy_entry.state is ConfigEntryState.SETUP_ERROR
# A reauth flow should be started
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "pick_implementation"
assert flows[0]["context"]["source"] == "reauth"

View File

@@ -374,7 +374,7 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None:
pubnub = AugustPubNub() pubnub = AugustPubNub()
activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json")
config_entry = await _create_august_with_devices( config_entry, _ = await _create_august_with_devices(
hass, [lock_one], activities=activities, pubnub=pubnub hass, [lock_one], activities=activities, pubnub=pubnub
) )
pubnub.connected = True pubnub.connected = True