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:
Will Pike
2026-05-26 22:07:35 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 1070226acf
commit 27b0ba1a25
5 changed files with 982 additions and 31 deletions
+24 -1
View File
@@ -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 = {
+129 -18
View File
@@ -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)
+16 -1
View File
@@ -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%]"
+507 -11
View File
@@ -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"
+306
View File
@@ -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"