mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Bump python-ecobee-api to 0.4.0 and handle MFA in ecobee config flow (#172101)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
1070226acf
commit
27b0ba1a25
@@ -8,12 +8,16 @@ from pyecobee import (
|
||||
ECOBEE_REFRESH_TOKEN,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS
|
||||
@@ -102,7 +106,26 @@ class EcobeeData:
|
||||
async def refresh(self) -> bool:
|
||||
"""Refresh ecobee tokens and update config entry."""
|
||||
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
|
||||
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
|
||||
try:
|
||||
success = await self._hass.async_add_executor_job(
|
||||
self.ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"ecobee account requires MFA; reauthentication needed"
|
||||
) from err
|
||||
except EcobeeAuthFailedError as err:
|
||||
if self.ecobee.config.get(ECOBEE_USERNAME):
|
||||
raise ConfigEntryAuthFailed(
|
||||
"ecobee rejected stored credentials"
|
||||
) from err
|
||||
_LOGGER.error("Ecobee rejected stored credentials: %s", err)
|
||||
return False
|
||||
except EcobeeAuthUnknownError:
|
||||
_LOGGER.exception("Unexpected error refreshing ecobee tokens")
|
||||
return False
|
||||
|
||||
if success:
|
||||
data = {}
|
||||
if self.ecobee.config.get(ECOBEE_API_KEY):
|
||||
data = {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Config flow to configure ecobee."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
|
||||
from pyecobee import (
|
||||
ECOBEE_API_KEY,
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
MfaChallenge,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
|
||||
@@ -18,6 +28,9 @@ _USER_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
_MFA_SCHEMA = vol.Schema({vol.Required(CONF_CODE): str})
|
||||
_REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||
|
||||
|
||||
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle an ecobee config flow."""
|
||||
@@ -25,12 +38,15 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
_ecobee: Ecobee
|
||||
_mfa_challenge: MfaChallenge | None = None
|
||||
_pending_username: str | None = None
|
||||
_pending_password: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
@@ -38,27 +54,34 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
if api_key and not (username or password):
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
elif username and password and not api_key:
|
||||
self._pending_username = username
|
||||
self._pending_password = password
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
}
|
||||
)
|
||||
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
|
||||
config = {
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=DOMAIN, data=config)
|
||||
errors["base"] = "login_failed"
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
self._mfa_challenge = err.args[0]
|
||||
return await self.async_step_mfa()
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "login_failed"
|
||||
else:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -68,16 +91,46 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Collect an MFA OTP code and complete the login."""
|
||||
assert self._mfa_challenge is not None
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
code = user_input[CONF_CODE].strip()
|
||||
if not code:
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
else:
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.submit_mfa_code, self._mfa_challenge, code
|
||||
)
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=_MFA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"mfa_type": self._mfa_challenge.mfa_type},
|
||||
)
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Present the user with the PIN to authorize on ecobee.com."""
|
||||
errors = {}
|
||||
"""Present the user with the PIN so that the app can be authorized on ecobee.com."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Attempt to obtain tokens from ecobee and finish the flow.
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_tokens):
|
||||
# Refresh token obtained; create the config entry.
|
||||
config = {
|
||||
CONF_API_KEY: self._ecobee.api_key,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
@@ -93,3 +146,61 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an ecobee authentication error."""
|
||||
self._pending_username = entry_data.get(CONF_USERNAME)
|
||||
self._pending_password = entry_data.get(CONF_PASSWORD)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Re-run the web login. May surface a fresh MFA challenge."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._pending_password = user_input[CONF_PASSWORD]
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: self._pending_username,
|
||||
ECOBEE_PASSWORD: self._pending_password,
|
||||
}
|
||||
)
|
||||
try:
|
||||
success = await self.hass.async_add_executor_job(
|
||||
self._ecobee.refresh_tokens
|
||||
)
|
||||
except EcobeeAuthMfaRequiredError as err:
|
||||
self._mfa_challenge = err.args[0]
|
||||
return await self.async_step_mfa()
|
||||
except EcobeeAuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EcobeeAuthUnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if success:
|
||||
return self._async_create_or_update_entry()
|
||||
errors["base"] = "login_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=_REAUTH_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"username": self._pending_username or ""},
|
||||
)
|
||||
|
||||
def _async_create_or_update_entry(self) -> ConfigFlowResult:
|
||||
"""Create a new entry or update the existing one on reauth."""
|
||||
data = {
|
||||
CONF_USERNAME: self._pending_username,
|
||||
CONF_PASSWORD: self._pending_password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
return self.async_create_entry(title=DOMAIN, data=data)
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_mfa_code": "The MFA code was not accepted by ecobee; please try again.",
|
||||
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
|
||||
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again."
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"authorize": {
|
||||
"description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**."
|
||||
},
|
||||
"mfa": {
|
||||
"data": {
|
||||
"code": "MFA code"
|
||||
},
|
||||
"description": "ecobee requires multi-factor authentication. Enter the {mfa_type} code from your authenticator app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Reauthenticate the ecobee account for **{username}**."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyecobee import ECOBEE_PASSWORD, ECOBEE_USERNAME
|
||||
from pyecobee import (
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_USERNAME,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
MfaChallenge,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
@@ -15,6 +22,17 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def _mfa_challenge() -> MfaChallenge:
|
||||
"""Return a minimal MfaChallenge payload for tests."""
|
||||
return MfaChallenge(
|
||||
challenge_url="https://auth.ecobee.com/u/mfa-otp-challenge?state=abc",
|
||||
state="abc",
|
||||
mfa_type="otp",
|
||||
cookies={},
|
||||
code_verifier="verifier",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Prevent the actual integration from being set up."""
|
||||
@@ -70,14 +88,13 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
async def test_pin_request_fails(hass: HomeAssistant) -> None:
|
||||
"""Test expected result if pin request fails."""
|
||||
"""Test expected result if pin request fails, then recovers on retry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee:
|
||||
mock_ecobee = mock_ecobee.return_value
|
||||
mock_ecobee.request_pin.return_value = False
|
||||
mock_ecobee.return_value.request_pin.return_value = False
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
|
||||
@@ -87,6 +104,32 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "pin_request_failed"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.request_pin.return_value = True
|
||||
flow_instance.pin = "test-pin"
|
||||
flow_instance.request_tokens.return_value = True
|
||||
flow_instance.api_key = "test-api-key"
|
||||
flow_instance.refresh_token = "test-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_API_KEY: "api-key"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "test-api-key",
|
||||
CONF_REFRESH_TOKEN: "test-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_token_request_succeeds(hass: HomeAssistant) -> None:
|
||||
"""Test expected result if token request succeeds."""
|
||||
@@ -123,7 +166,7 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
async def test_token_request_fails(hass: HomeAssistant) -> None:
|
||||
"""Test expected result if token request fails."""
|
||||
"""Test expected result if token request fails, then recovers on retry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
@@ -147,12 +190,26 @@ async def test_token_request_fails(hass: HomeAssistant) -> None:
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
assert result["errors"]["base"] == "token_request_failed"
|
||||
assert result["description_placeholders"] == {
|
||||
"pin": "test-pin",
|
||||
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
assert result["errors"]["base"] == "token_request_failed"
|
||||
assert result["description_placeholders"] == {
|
||||
"pin": "test-pin",
|
||||
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
|
||||
}
|
||||
|
||||
flow_instance.request_tokens.return_value = True
|
||||
flow_instance.api_key = "test-api-key"
|
||||
flow_instance.refresh_token = "test-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "test-api-key",
|
||||
CONF_REFRESH_TOKEN: "test-token",
|
||||
}
|
||||
|
||||
|
||||
@@ -258,3 +315,442 @@ async def test_password_login_error_recovers(
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(EcobeeAuthFailedError("bad creds"), "invalid_auth"),
|
||||
(EcobeeAuthUnknownError("network down"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_password_login_raises_auth_error(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that pyecobee auth exceptions map to user-facing form errors, then recover."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
mock_flow_ecobee.return_value.refresh_tokens.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.return_value = True
|
||||
flow_instance.refresh_token = "test-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_password_login_with_mfa_challenge_succeeds(hass: HomeAssistant) -> None:
|
||||
"""Test the MFA branch: password POST triggers MFA, code completes login."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
challenge = _mfa_challenge()
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError(challenge)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["description_placeholders"] == {"mfa_type": "otp"}
|
||||
|
||||
flow_instance.submit_mfa_code.return_value = True
|
||||
flow_instance.refresh_token = "test-token-after-mfa"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token-after-mfa",
|
||||
}
|
||||
flow_instance.submit_mfa_code.assert_called_once_with(challenge, "123456")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(EcobeeAuthFailedError("wrong code"), "invalid_mfa_code"),
|
||||
(EcobeeAuthUnknownError("auth0 hiccup"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_mfa_submission_errors_recover(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that errors during MFA submission keep the user on the form and recover."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError(
|
||||
_mfa_challenge()
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
assert result["step_id"] == "mfa"
|
||||
|
||||
flow_instance.submit_mfa_code.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "999999"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
flow_instance.submit_mfa_code.side_effect = None
|
||||
flow_instance.submit_mfa_code.return_value = True
|
||||
flow_instance.refresh_token = "test-token-after-recovery"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token-after-recovery",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_code", ["", " ", "\t\n "])
|
||||
async def test_mfa_submission_rejects_blank_code(
|
||||
hass: HomeAssistant, blank_code: str
|
||||
) -> None:
|
||||
"""Whitespace-only MFA code is rejected client-side, flow then recovers."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError(
|
||||
_mfa_challenge()
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
assert result["step_id"] == "mfa"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": blank_code}
|
||||
)
|
||||
|
||||
flow_instance.submit_mfa_code.assert_not_called()
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == "invalid_mfa_code"
|
||||
|
||||
flow_instance.submit_mfa_code.return_value = True
|
||||
flow_instance.refresh_token = "test-token-after-recovery"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token-after-recovery",
|
||||
}
|
||||
|
||||
|
||||
async def test_reauth_flow_succeeds(hass: HomeAssistant) -> None:
|
||||
"""Test the reauth flow updates the existing entry with a fresh refresh_token."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "stale-password",
|
||||
CONF_REFRESH_TOKEN: "stale-refresh-token",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"]["username"] == "test-username@example.com"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.return_value = True
|
||||
flow_instance.refresh_token = "fresh-refresh-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "new-password",
|
||||
CONF_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_reauth_flow_with_mfa_challenge(hass: HomeAssistant) -> None:
|
||||
"""Test that reauth surfacing MFA routes through the same mfa step."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "stale-password",
|
||||
CONF_REFRESH_TOKEN: "stale-refresh-token",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
challenge = _mfa_challenge()
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError(challenge)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["step_id"] == "mfa"
|
||||
|
||||
flow_instance.submit_mfa_code.return_value = True
|
||||
flow_instance.refresh_token = "reauth-refresh-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_REFRESH_TOKEN] == "reauth-refresh-token"
|
||||
assert entry.data[CONF_PASSWORD] == "new-password"
|
||||
flow_instance.submit_mfa_code.assert_called_once_with(challenge, "123456")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(EcobeeAuthFailedError("bad creds"), "invalid_auth"),
|
||||
(EcobeeAuthUnknownError("network down"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_flow_error_branches(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Auth errors during reauth keep the user on the reauth form, then recover."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "stale-password",
|
||||
CONF_REFRESH_TOKEN: "stale-refresh-token",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
mock_flow_ecobee.return_value.refresh_tokens.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "wrong-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.return_value = True
|
||||
flow_instance.refresh_token = "fresh-refresh-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "new-password",
|
||||
CONF_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_mfa_step_submit_returns_false(hass: HomeAssistant) -> None:
|
||||
"""submit_mfa_code returning False surfaces invalid_mfa_code, then recovers."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError(
|
||||
_mfa_challenge()
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
assert result["step_id"] == "mfa"
|
||||
|
||||
flow_instance.submit_mfa_code.return_value = False
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "999999"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == "invalid_mfa_code"
|
||||
|
||||
flow_instance.submit_mfa_code.return_value = True
|
||||
flow_instance.refresh_token = "test-token-after-recovery"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"code": "123456"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-token-after-recovery",
|
||||
}
|
||||
|
||||
|
||||
async def test_reauth_returns_false_surfaces_login_failed(hass: HomeAssistant) -> None:
|
||||
"""refresh_tokens returning False during reauth surfaces login_failed, then recovers."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "test-username@example.com",
|
||||
CONF_PASSWORD: "stale-password",
|
||||
CONF_REFRESH_TOKEN: "stale-refresh-token",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
mock_flow_ecobee.return_value.refresh_tokens.return_value = False
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "wrong-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"]["base"] == "login_failed"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ecobee.config_flow.Ecobee"
|
||||
) as mock_flow_ecobee:
|
||||
flow_instance = mock_flow_ecobee.return_value
|
||||
flow_instance.refresh_tokens.return_value = True
|
||||
flow_instance.refresh_token = "fresh-refresh-token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_REFRESH_TOKEN] == "fresh-refresh-token"
|
||||
assert entry.data[CONF_PASSWORD] == "new-password"
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for the ecobee integration setup and refresh paths."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyecobee import (
|
||||
ECOBEE_API_KEY,
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_REFRESH_TOKEN,
|
||||
ECOBEE_USERNAME,
|
||||
EcobeeAuthFailedError,
|
||||
EcobeeAuthMfaRequiredError,
|
||||
EcobeeAuthUnknownError,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import GENERIC_THERMOSTAT_INFO
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
def _api_key_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return a registered MockConfigEntry using the PIN/API-key data shape."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_API_KEY: "test-api-key", CONF_REFRESH_TOKEN: "test-refresh-token"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _credentials_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return a registered MockConfigEntry using the username/password data shape."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_USERNAME: "user@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "test-refresh-token",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
_DEFAULT_THERMOSTATS = object()
|
||||
|
||||
|
||||
def _build_mock_ecobee(
|
||||
*,
|
||||
refresh_returns: bool = True,
|
||||
config: dict | None = None,
|
||||
thermostats: list | None = _DEFAULT_THERMOSTATS,
|
||||
) -> MagicMock:
|
||||
"""Return a MagicMock shaped like a successfully-refreshed pyecobee.Ecobee."""
|
||||
ecobee = MagicMock()
|
||||
ecobee.refresh_tokens.return_value = refresh_returns
|
||||
ecobee.thermostats = (
|
||||
[GENERIC_THERMOSTAT_INFO]
|
||||
if thermostats is _DEFAULT_THERMOSTATS
|
||||
else thermostats
|
||||
)
|
||||
ecobee.get_thermostat = lambda index: ecobee.thermostats[index]
|
||||
ecobee.config = (
|
||||
{ECOBEE_API_KEY: "test-api-key", ECOBEE_REFRESH_TOKEN: "new-refresh-token"}
|
||||
if config is None
|
||||
else config
|
||||
)
|
||||
return ecobee
|
||||
|
||||
|
||||
async def _setup_with_mock(
|
||||
hass: HomeAssistant,
|
||||
entry: MockConfigEntry,
|
||||
ecobee: MagicMock,
|
||||
*,
|
||||
platforms: list[Platform] | None = None,
|
||||
) -> bool:
|
||||
"""Set up the entry with a patched pyecobee.Ecobee returning ``ecobee``."""
|
||||
with (
|
||||
patch("homeassistant.components.ecobee.Ecobee", return_value=ecobee),
|
||||
patch(
|
||||
"homeassistant.components.ecobee.PLATFORMS",
|
||||
[] if platforms is None else platforms,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return result
|
||||
|
||||
|
||||
def _has_reauth_flow(hass: HomeAssistant) -> bool:
|
||||
"""Return True if the ecobee config flow has an in-progress reauth flow."""
|
||||
return any(
|
||||
flow["context"].get("source") == SOURCE_REAUTH
|
||||
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_succeeds_with_api_key_entry(hass: HomeAssistant) -> None:
|
||||
"""A PIN/API-key entry sets up cleanly when pyecobee returns thermostats."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee()
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is True
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_setup_succeeds_with_credentials_entry(hass: HomeAssistant) -> None:
|
||||
"""A username/password entry sets up cleanly when pyecobee returns thermostats."""
|
||||
entry = _credentials_entry(hass)
|
||||
ecobee = _build_mock_ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: "user@example.com",
|
||||
ECOBEE_PASSWORD: "test-password",
|
||||
ECOBEE_REFRESH_TOKEN: "new-refresh-token",
|
||||
}
|
||||
)
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is True
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_setup_rejects_entry_with_no_credentials(hass: HomeAssistant) -> None:
|
||||
"""An entry missing both API key and username/password fails setup."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_REFRESH_TOKEN: "test-refresh-token"}
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await _setup_with_mock(hass, entry, _build_mock_ecobee()) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_fails_when_refresh_returns_false(hass: HomeAssistant) -> None:
|
||||
"""A False return from pyecobee.refresh_tokens aborts setup."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee(refresh_returns=False)
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert not _has_reauth_flow(hass)
|
||||
|
||||
|
||||
async def test_setup_fails_when_no_thermostats(hass: HomeAssistant) -> None:
|
||||
"""Setup aborts when ecobee.com returns no thermostats."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee(thermostats=None)
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_triggers_reauth_on_mfa_required(hass: HomeAssistant) -> None:
|
||||
"""EcobeeAuthMfaRequiredError during setup raises ConfigEntryAuthFailed → reauth."""
|
||||
entry = _credentials_entry(hass)
|
||||
ecobee = _build_mock_ecobee()
|
||||
ecobee.refresh_tokens.side_effect = EcobeeAuthMfaRequiredError("mfa")
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert _has_reauth_flow(hass)
|
||||
|
||||
|
||||
async def test_setup_triggers_reauth_on_auth_failed_with_username(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""EcobeeAuthFailedError on a credentials entry raises ConfigEntryAuthFailed → reauth."""
|
||||
entry = _credentials_entry(hass)
|
||||
ecobee = _build_mock_ecobee(
|
||||
config={ECOBEE_USERNAME: "user@example.com", ECOBEE_PASSWORD: "test-password"}
|
||||
)
|
||||
ecobee.refresh_tokens.side_effect = EcobeeAuthFailedError("bad creds")
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert _has_reauth_flow(hass)
|
||||
|
||||
|
||||
async def test_setup_no_reauth_on_auth_failed_without_username(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""API-key entries surface EcobeeAuthFailedError as a False return, not reauth."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee(config={ECOBEE_API_KEY: "test-api-key"})
|
||||
ecobee.refresh_tokens.side_effect = EcobeeAuthFailedError("bad creds")
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert not _has_reauth_flow(hass)
|
||||
|
||||
|
||||
async def test_setup_no_reauth_on_unknown_error(hass: HomeAssistant) -> None:
|
||||
"""EcobeeAuthUnknownError is treated as transient — no reauth flow is started."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee()
|
||||
ecobee.refresh_tokens.side_effect = EcobeeAuthUnknownError("network")
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is False
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert not _has_reauth_flow(hass)
|
||||
|
||||
|
||||
async def test_setup_recovers_from_expired_token_during_update(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""update() catches ExpiredTokenError and triggers refresh() in the same setup pass."""
|
||||
entry = _api_key_entry(hass)
|
||||
ecobee = _build_mock_ecobee()
|
||||
ecobee.update.side_effect = ExpiredTokenError("expired")
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is True
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
# refresh_tokens runs twice: once during async_setup_entry's refresh(), and
|
||||
# again from update()'s ExpiredTokenError branch.
|
||||
assert ecobee.refresh_tokens.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_data"),
|
||||
[
|
||||
(
|
||||
{
|
||||
ECOBEE_API_KEY: "test-api-key",
|
||||
ECOBEE_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
},
|
||||
{
|
||||
CONF_API_KEY: "test-api-key",
|
||||
CONF_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
},
|
||||
),
|
||||
(
|
||||
{
|
||||
ECOBEE_USERNAME: "user@example.com",
|
||||
ECOBEE_PASSWORD: "test-password",
|
||||
ECOBEE_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
},
|
||||
{
|
||||
CONF_USERNAME: "user@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_REFRESH_TOKEN: "fresh-refresh-token",
|
||||
},
|
||||
),
|
||||
],
|
||||
ids=["api_key", "credentials"],
|
||||
)
|
||||
async def test_setup_persists_refreshed_credentials_to_entry(
|
||||
hass: HomeAssistant,
|
||||
config: dict,
|
||||
expected_data: dict,
|
||||
) -> None:
|
||||
"""A successful refresh writes the new refresh_token back to the entry."""
|
||||
entry = (
|
||||
_credentials_entry(hass) if ECOBEE_USERNAME in config else _api_key_entry(hass)
|
||||
)
|
||||
ecobee = _build_mock_ecobee(config=config)
|
||||
|
||||
assert await _setup_with_mock(hass, entry, ecobee) is True
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.data == expected_data
|
||||
|
||||
|
||||
async def test_runtime_refresh_persists_new_refresh_token(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""A natural runtime refresh writes the rotated refresh_token back to the entry.
|
||||
|
||||
Sets up successfully, then advances time past the climate platform's scan
|
||||
interval + EcobeeData's MIN_TIME_BETWEEN_UPDATES throttle so a real entity
|
||||
poll calls update() → ExpiredTokenError → refresh() → entry update.
|
||||
"""
|
||||
entry = _credentials_entry(hass)
|
||||
ecobee = _build_mock_ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: "user@example.com",
|
||||
ECOBEE_PASSWORD: "test-password",
|
||||
ECOBEE_REFRESH_TOKEN: "first-refresh-token",
|
||||
}
|
||||
)
|
||||
|
||||
assert (
|
||||
await _setup_with_mock(hass, entry, ecobee, platforms=[Platform.CLIMATE])
|
||||
is True
|
||||
)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.data[CONF_REFRESH_TOKEN] == "first-refresh-token"
|
||||
|
||||
ecobee.update.side_effect = ExpiredTokenError("expired")
|
||||
ecobee.config = {
|
||||
ECOBEE_USERNAME: "user@example.com",
|
||||
ECOBEE_PASSWORD: "test-password",
|
||||
ECOBEE_REFRESH_TOKEN: "rotated-refresh-token",
|
||||
}
|
||||
|
||||
freezer.tick(timedelta(seconds=300))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_REFRESH_TOKEN] == "rotated-refresh-token"
|
||||
Reference in New Issue
Block a user