Fix PG&E and Duquesne Light Company in Opower (#149658)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
tronikos
2025-08-06 02:32:42 -07:00
committed by GitHub
parent 0db23b0da6
commit 0aeff366bd
8 changed files with 544 additions and 155 deletions

View File

@@ -9,6 +9,8 @@ from typing import Any
from opower import ( from opower import (
CannotConnect, CannotConnect,
InvalidAuth, InvalidAuth,
MfaChallenge,
MfaHandlerBase,
Opower, Opower,
create_cookie_jar, create_cookie_jar,
get_supported_utility_names, get_supported_utility_names,
@@ -16,49 +18,34 @@ from opower import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.typing import VolDictType
from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_MFA_CODE = "mfa_code"
STEP_USER_DATA_SCHEMA = vol.Schema( CONF_MFA_METHOD = "mfa_method"
{
vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
async def _validate_login( async def _validate_login(
hass: HomeAssistant, login_data: dict[str, str] hass: HomeAssistant,
) -> dict[str, str]: data: Mapping[str, Any],
"""Validate login data and return any errors.""" ) -> None:
"""Validate login data and raise exceptions on failure."""
api = Opower( api = Opower(
async_create_clientsession(hass, cookie_jar=create_cookie_jar()), async_create_clientsession(hass, cookie_jar=create_cookie_jar()),
login_data[CONF_UTILITY], data[CONF_UTILITY],
login_data[CONF_USERNAME], data[CONF_USERNAME],
login_data[CONF_PASSWORD], data[CONF_PASSWORD],
login_data.get(CONF_TOTP_SECRET), data.get(CONF_TOTP_SECRET),
data.get(CONF_LOGIN_DATA),
) )
errors: dict[str, str] = {} await api.async_login()
try:
await api.async_login()
except InvalidAuth:
_LOGGER.exception(
"Invalid auth when connecting to %s", login_data[CONF_UTILITY]
)
errors["base"] = "invalid_auth"
except CannotConnect:
_LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY])
errors["base"] = "cannot_connect"
return errors
class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -68,81 +55,147 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize a new OpowerConfigFlow.""" """Initialize a new OpowerConfigFlow."""
self.utility_info: dict[str, Any] | None = None self._data: dict[str, Any] = {}
self.mfa_handler: MfaHandlerBase | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step (select utility)."""
errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._async_abort_entries_match( self._data[CONF_UTILITY] = user_input[CONF_UTILITY]
{ return await self.async_step_credentials()
CONF_UTILITY: user_input[CONF_UTILITY],
CONF_USERNAME: user_input[CONF_USERNAME],
}
)
if select_utility(user_input[CONF_UTILITY]).accepts_mfa():
self.utility_info = user_input
return await self.async_step_mfa()
errors = await _validate_login(self.hass, user_input)
if not errors:
return self._async_create_opower_entry(user_input)
else:
user_input = {}
user_input.pop(CONF_PASSWORD, None)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())}
),
)
async def async_step_credentials(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle credentials step."""
errors: dict[str, str] = {}
utility = select_utility(self._data[CONF_UTILITY])
if user_input is not None:
self._data.update(user_input)
self._async_abort_entries_match(
{
CONF_UTILITY: self._data[CONF_UTILITY],
CONF_USERNAME: self._data[CONF_USERNAME],
}
)
try:
await _validate_login(self.hass, self._data)
except MfaChallenge as exc:
self.mfa_handler = exc.handler
return await self.async_step_mfa_options()
except InvalidAuth:
errors["base"] = "invalid_auth"
except CannotConnect:
errors["base"] = "cannot_connect"
else:
return self._async_create_opower_entry(self._data)
schema_dict: VolDictType = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
if utility.accepts_totp_secret():
schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str
return self.async_show_form(
step_id="credentials",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input vol.Schema(schema_dict), user_input
), ),
errors=errors, errors=errors,
) )
async def async_step_mfa( async def async_step_mfa_options(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle MFA step.""" """Handle MFA options step."""
assert self.utility_info is not None errors: dict[str, str] = {}
assert self.mfa_handler is not None
if user_input is not None:
method = user_input[CONF_MFA_METHOD]
try:
await self.mfa_handler.async_select_mfa_option(method)
except CannotConnect:
errors["base"] = "cannot_connect"
else:
return await self.async_step_mfa_code()
mfa_options = await self.mfa_handler.async_get_mfa_options()
if not mfa_options:
return await self.async_step_mfa_code()
return self.async_show_form(
step_id="mfa_options",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}),
user_input,
),
errors=errors,
)
async def async_step_mfa_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle MFA code submission step."""
assert self.mfa_handler is not None
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
data = {**self.utility_info, **user_input} code = user_input[CONF_MFA_CODE]
errors = await _validate_login(self.hass, data) try:
if not errors: login_data = await self.mfa_handler.async_submit_mfa_code(code)
return self._async_create_opower_entry(data) except InvalidAuth:
errors["base"] = "invalid_mfa_code"
if errors: except CannotConnect:
schema = { errors["base"] = "cannot_connect"
vol.Required( else:
CONF_USERNAME, default=self.utility_info[CONF_USERNAME] self._data[CONF_LOGIN_DATA] = login_data
): str, if self.source == SOURCE_REAUTH:
vol.Required(CONF_PASSWORD): str, return self.async_update_reload_and_abort(
} self._get_reauth_entry(), data=self._data
else: )
schema = {} return self._async_create_opower_entry(self._data)
schema[vol.Required(CONF_TOTP_SECRET)] = str
return self.async_show_form( return self.async_show_form(
step_id="mfa", step_id="mfa_code",
data_schema=vol.Schema(schema), data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input
),
errors=errors, errors=errors,
) )
@callback @callback
def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult: def _async_create_opower_entry(
self, data: dict[str, Any], **kwargs: Any
) -> ConfigFlowResult:
"""Create the config entry.""" """Create the config entry."""
return self.async_create_entry( return self.async_create_entry(
title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})",
data=data, data=data,
**kwargs,
) )
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."""
return await self.async_step_reauth_confirm() reauth_entry = self._get_reauth_entry()
self._data = dict(reauth_entry.data)
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: reauth_entry.title},
)
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -150,21 +203,34 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Dialog that informs the user that reauth is required.""" """Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry() reauth_entry = self._get_reauth_entry()
if user_input is not None:
data = {**reauth_entry.data, **user_input}
errors = await _validate_login(self.hass, data)
if not errors:
return self.async_update_reload_and_abort(reauth_entry, data=data)
schema: VolDictType = { if user_input is not None:
vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME], self._data.update(user_input)
try:
await _validate_login(self.hass, self._data)
except MfaChallenge as exc:
self.mfa_handler = exc.handler
return await self.async_step_mfa_options()
except InvalidAuth:
errors["base"] = "invalid_auth"
except CannotConnect:
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(reauth_entry, data=self._data)
utility = select_utility(self._data[CONF_UTILITY])
schema_dict: VolDictType = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
} }
if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa(): if utility.accepts_totp_secret():
schema[vol.Optional(CONF_TOTP_SECRET)] = str schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="reauth_confirm",
data_schema=vol.Schema(schema), data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), self._data
),
errors=errors, errors=errors,
description_placeholders={CONF_NAME: reauth_entry.title}, description_placeholders={CONF_NAME: reauth_entry.title},
) )

View File

@@ -4,3 +4,4 @@ DOMAIN = "opower"
CONF_UTILITY = "utility" CONF_UTILITY = "utility"
CONF_TOTP_SECRET = "totp_secret" CONF_TOTP_SECRET = "totp_secret"
CONF_LOGIN_DATA = "login_data"

View File

@@ -14,7 +14,7 @@ from opower import (
ReadResolution, ReadResolution,
create_cookie_jar, create_cookie_jar,
) )
from opower.exceptions import ApiException, CannotConnect, InvalidAuth from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge
from homeassistant.components.recorder import get_instance from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.models import (
@@ -36,7 +36,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -69,6 +69,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
config_entry.data[CONF_USERNAME], config_entry.data[CONF_USERNAME],
config_entry.data[CONF_PASSWORD], config_entry.data[CONF_PASSWORD],
config_entry.data.get(CONF_TOTP_SECRET), config_entry.data.get(CONF_TOTP_SECRET),
config_entry.data.get(CONF_LOGIN_DATA),
) )
@callback @callback
@@ -90,7 +91,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
# Given the infrequent updating (every 12h) # Given the infrequent updating (every 12h)
# assume previous session has expired and re-login. # assume previous session has expired and re-login.
await self.api.async_login() await self.api.async_login()
except InvalidAuth as err: except (InvalidAuth, MfaChallenge) as err:
_LOGGER.error("Error during login: %s", err) _LOGGER.error("Error during login: %s", err)
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
except CannotConnect as err: except CannotConnect as err:

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower", "documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["opower"], "loggers": ["opower"],
"requirements": ["opower==0.12.4"] "requirements": ["opower==0.15.1"]
} }

View File

@@ -3,27 +3,43 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"utility": "Utility name", "utility": "Utility name"
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}, },
"data_description": { "data_description": {
"utility": "The name of your utility provider", "utility": "The name of your utility provider"
"username": "The username for your utility account",
"password": "The password for your utility account"
} }
}, },
"mfa": { "credentials": {
"description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "title": "Enter Credentials",
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"totp_secret": "TOTP secret" "totp_secret": "TOTP secret"
}, },
"data_description": { "data_description": {
"username": "[%key:component::opower::config::step::user::data_description::username%]", "username": "The username for your utility account",
"password": "[%key:component::opower::config::step::user::data_description::password%]", "password": "The password for your utility account",
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." "totp_secret": "This is not a 6-digit code. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation."
}
},
"mfa_options": {
"title": "Multi-factor authentication",
"description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.",
"data": {
"mfa_method": "MFA method"
},
"data_description": {
"mfa_method": "How to receive your security code"
}
},
"mfa_code": {
"title": "Enter security code",
"description": "A security code has been sent via your selected method. Please enter it below to complete login.",
"data": {
"mfa_code": "Security code"
},
"data_description": {
"mfa_code": "Typically a 6-digit code"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@@ -31,18 +47,19 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" "totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]"
}, },
"data_description": { "data_description": {
"username": "[%key:component::opower::config::step::user::data_description::username%]", "username": "[%key:component::opower::config::step::credentials::data_description::username%]",
"password": "[%key:component::opower::config::step::user::data_description::password%]", "password": "[%key:component::opower::config::step::credentials::data_description::password%]",
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." "totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_mfa_code": "The security code is incorrect. Please try again."
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

2
requirements_all.txt generated
View File

@@ -1628,7 +1628,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2 openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower # homeassistant.components.opower
opower==0.12.4 opower==0.15.1
# homeassistant.components.oralb # homeassistant.components.oralb
oralb-ble==0.17.6 oralb-ble==0.17.6

View File

@@ -1384,7 +1384,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1 openwebifpy==4.3.1
# homeassistant.components.opower # homeassistant.components.opower
opower==0.12.4 opower==0.15.1
# homeassistant.components.oralb # homeassistant.components.oralb
oralb-ble==0.17.6 oralb-ble==0.17.6

View File

@@ -3,7 +3,7 @@
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from opower import CannotConnect, InvalidAuth from opower import CannotConnect, InvalidAuth, MfaChallenge
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
@@ -43,24 +43,32 @@ async def test_form(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert not result["errors"] assert result["step_id"] == "user"
# Select utility
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "credentials"
# Enter credentials
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login: ) as mock_login:
result2 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username", "username": "test-username",
"password": "test-password", "password": "test-password",
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)"
assert result2["data"] == { assert result3["data"] == {
"utility": "Pacific Gas and Electric Company (PG&E)", "utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username", "username": "test-username",
"password": "test-password", "password": "test-password",
@@ -69,33 +77,33 @@ async def test_form(
assert mock_login.call_count == 1 assert mock_login.call_count == 1
async def test_form_with_mfa( async def test_form_with_totp(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None: ) -> None:
"""Test we get the form.""" """Test we can configure a utility that accepts a TOTP secret."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert not result["errors"] assert result["step_id"] == "user"
# Select utility
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {"utility": "Consolidated Edison (ConEd)"},
"utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "test-password",
},
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert not result2["errors"] assert result2["step_id"] == "credentials"
# Enter credentials
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login: ) as mock_login:
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"username": "test-username",
"password": "test-password",
"totp_secret": "test-totp", "totp_secret": "test-totp",
}, },
) )
@@ -112,43 +120,42 @@ async def test_form_with_mfa(
assert mock_login.call_count == 1 assert mock_login.call_count == 1
async def test_form_with_mfa_bad_secret( async def test_form_with_invalid_totp(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None: ) -> None:
"""Test MFA asks for password again when validation fails.""" """Test we handle an invalid TOTP secret."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert not result["errors"] assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {"utility": "Consolidated Edison (ConEd)"},
"utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "test-password",
},
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert not result2["errors"] assert result2["step_id"] == "credentials"
# Enter invalid credentials
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=InvalidAuth, side_effect=InvalidAuth,
) as mock_login: ):
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"totp_secret": "test-totp", "username": "test-username",
"password": "test-password",
"totp_secret": "bad-totp",
}, },
) )
assert result3["type"] is FlowResultType.FORM assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == { assert result3["errors"] == {"base": "invalid_auth"}
"base": "invalid_auth", assert result3["step_id"] == "credentials"
}
# Enter valid credentials
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login: ) as mock_login:
@@ -157,7 +164,7 @@ async def test_form_with_mfa_bad_secret(
{ {
"username": "test-username", "username": "test-username",
"password": "updated-password", "password": "updated-password",
"totp_secret": "updated-totp", "totp_secret": "good-totp",
}, },
) )
@@ -167,26 +174,195 @@ async def test_form_with_mfa_bad_secret(
"utility": "Consolidated Edison (ConEd)", "utility": "Consolidated Edison (ConEd)",
"username": "test-username", "username": "test-username",
"password": "updated-password", "password": "updated-password",
"totp_secret": "updated-totp", "totp_secret": "good-totp",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert mock_login.call_count == 1 assert mock_login.call_count == 1
async def test_form_with_mfa_challenge(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test the full interactive MFA flow, including error recovery."""
# 1. Start the flow and get to the credentials step
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
# 2. Trigger an MfaChallenge on login
mock_mfa_handler = AsyncMock()
mock_mfa_handler.async_get_mfa_options.return_value = {
"Email": "fooxxx@mail.com",
"Phone": "xxx-123",
}
mock_mfa_handler.async_submit_mfa_code.return_value = {
"login_data_mock_key": "login_data_mock_value"
}
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=MfaChallenge(message="", handler=mock_mfa_handler),
) as mock_login:
result_challenge = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
mock_login.assert_awaited_once()
# 3. Handle the MFA options step, starting with a connection error
assert result_challenge["type"] is FlowResultType.FORM
assert result_challenge["step_id"] == "mfa_options"
mock_mfa_handler.async_get_mfa_options.assert_awaited_once()
# Test CannotConnect on selecting MFA method
mock_mfa_handler.async_select_mfa_option.side_effect = CannotConnect
result_mfa_connect_fail = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_method": "Email"}
)
mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Email")
assert result_mfa_connect_fail["type"] is FlowResultType.FORM
assert result_mfa_connect_fail["step_id"] == "mfa_options"
assert result_mfa_connect_fail["errors"] == {"base": "cannot_connect"}
# Retry selecting MFA method successfully
mock_mfa_handler.async_select_mfa_option.side_effect = None
result_mfa_select_ok = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_method": "Email"}
)
assert mock_mfa_handler.async_select_mfa_option.call_count == 2
assert result_mfa_select_ok["type"] is FlowResultType.FORM
assert result_mfa_select_ok["step_id"] == "mfa_code"
# 4. Handle the MFA code step, testing multiple failure scenarios
# Test InvalidAuth on submitting code
mock_mfa_handler.async_submit_mfa_code.side_effect = InvalidAuth
result_mfa_invalid_code = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "bad-code"}
)
mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("bad-code")
assert result_mfa_invalid_code["type"] is FlowResultType.FORM
assert result_mfa_invalid_code["step_id"] == "mfa_code"
assert result_mfa_invalid_code["errors"] == {"base": "invalid_mfa_code"}
# Test CannotConnect on submitting code
mock_mfa_handler.async_submit_mfa_code.side_effect = CannotConnect
result_mfa_code_connect_fail = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "good-code"}
)
assert mock_mfa_handler.async_submit_mfa_code.call_count == 2
mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code")
assert result_mfa_code_connect_fail["type"] is FlowResultType.FORM
assert result_mfa_code_connect_fail["step_id"] == "mfa_code"
assert result_mfa_code_connect_fail["errors"] == {"base": "cannot_connect"}
# Retry submitting code successfully
mock_mfa_handler.async_submit_mfa_code.side_effect = None
result_final = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "good-code"}
)
assert mock_mfa_handler.async_submit_mfa_code.call_count == 3
mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code")
# 5. Verify the flow completes and creates the entry
assert result_final["type"] is FlowResultType.CREATE_ENTRY
assert (
result_final["title"]
== "Pacific Gas and Electric Company (PG&E) (test-username)"
)
assert result_final["data"] == {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username",
"password": "test-password",
"login_data": {"login_data_mock_key": "login_data_mock_value"},
}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_with_mfa_challenge_but_no_mfa_options(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test the full interactive MFA flow when there are no MFA options."""
# 1. Start the flow and get to the credentials step
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
# 2. Trigger an MfaChallenge on login
mock_mfa_handler = AsyncMock()
mock_mfa_handler.async_get_mfa_options.return_value = {}
mock_mfa_handler.async_submit_mfa_code.return_value = {
"login_data_mock_key": "login_data_mock_value"
}
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=MfaChallenge(message="", handler=mock_mfa_handler),
) as mock_login:
result_challenge = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
mock_login.assert_awaited_once()
# 3. No MFA options. Handle the MFA code step
assert result_challenge["type"] is FlowResultType.FORM
assert result_challenge["step_id"] == "mfa_code"
mock_mfa_handler.async_get_mfa_options.assert_awaited_once()
result_final = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "good-code"}
)
mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code")
# 4. Verify the flow completes and creates the entry
assert result_final["type"] is FlowResultType.CREATE_ENTRY
assert (
result_final["title"]
== "Pacific Gas and Electric Company (PG&E) (test-username)"
)
assert result_final["data"] == {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username",
"password": "test-password",
"login_data": {"login_data_mock_key": "login_data_mock_value"},
}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
("api_exception", "expected_error"), ("api_exception", "expected_error"),
[ [
(InvalidAuth(), "invalid_auth"), (InvalidAuth, "invalid_auth"),
(CannotConnect(), "cannot_connect"), (CannotConnect, "cannot_connect"),
], ],
) )
async def test_form_exceptions( async def test_form_exceptions(
recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error recorder_mock: Recorder,
hass: HomeAssistant,
api_exception: Exception,
expected_error: str,
) -> None: ) -> None:
"""Test we handle exceptions.""" """Test we handle exceptions."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
@@ -195,7 +371,6 @@ async def test_form_exceptions(
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username", "username": "test-username",
"password": "test-password", "password": "test-password",
}, },
@@ -203,15 +378,10 @@ async def test_form_exceptions(
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": expected_error} assert result2["errors"] == {"base": expected_error}
# On error, the form should have the previous user input, except password, # On error, the form should have the previous user input as suggested values.
# as suggested values.
data_schema = result2["data_schema"].schema data_schema = result2["data_schema"].schema
assert (
get_schema_suggested_value(data_schema, "utility")
== "Pacific Gas and Electric Company (PG&E)"
)
assert get_schema_suggested_value(data_schema, "username") == "test-username" assert get_schema_suggested_value(data_schema, "username") == "test-username"
assert get_schema_suggested_value(data_schema, "password") is None assert get_schema_suggested_value(data_schema, "password") == "test-password"
assert mock_login.call_count == 1 assert mock_login.call_count == 1
@@ -224,6 +394,10 @@ async def test_form_already_configured(
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
@@ -231,7 +405,6 @@ async def test_form_already_configured(
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username", "username": "test-username",
"password": "test-password", "password": "test-password",
}, },
@@ -252,6 +425,10 @@ async def test_form_not_already_configured(
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Pacific Gas and Electric Company (PG&E)"},
)
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
@@ -259,7 +436,6 @@ async def test_form_not_already_configured(
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username2", "username": "test-username2",
"password": "test-password", "password": "test-password",
}, },
@@ -299,6 +475,16 @@ async def test_form_valid_reauth(
assert result["context"]["source"] == "reauth" assert result["context"]["source"] == "reauth"
assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title}
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=InvalidAuth,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["data_schema"].schema.keys() == {
"username",
"password",
}
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login: ) as mock_login:
@@ -321,22 +507,23 @@ async def test_form_valid_reauth(
assert mock_login.call_count == 1 assert mock_login.call_count == 1
async def test_form_valid_reauth_with_mfa( async def test_form_valid_reauth_with_totp(
recorder_mock: Recorder, recorder_mock: Recorder,
hass: HomeAssistant, hass: HomeAssistant,
mock_setup_entry: AsyncMock, mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock, mock_unload_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test that we can handle a valid reauth.""" """Test that we can handle a valid reauth for a utility with TOTP."""
hass.config_entries.async_update_entry( mock_config_entry = MockConfigEntry(
mock_config_entry, title="Consolidated Edison (ConEd) (test-username)",
domain=DOMAIN,
data={ data={
**mock_config_entry.data,
# Requires MFA
"utility": "Consolidated Edison (ConEd)", "utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "test-password",
}, },
) )
mock_config_entry.add_to_hass(hass)
mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED)
hass.config.components.add(DOMAIN) hass.config.components.add(DOMAIN)
mock_config_entry.async_start_reauth(hass) mock_config_entry.async_start_reauth(hass)
@@ -346,6 +533,17 @@ async def test_form_valid_reauth_with_mfa(
assert len(flows) == 1 assert len(flows) == 1
result = flows[0] result = flows[0]
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=InvalidAuth,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["data_schema"].schema.keys() == {
"username",
"password",
"totp_secret",
}
with patch( with patch(
"homeassistant.components.opower.config_flow.Opower.async_login", "homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login: ) as mock_login:
@@ -371,3 +569,109 @@ async def test_form_valid_reauth_with_mfa(
assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert mock_login.call_count == 1 assert mock_login.call_count == 1
async def test_reauth_with_mfa_challenge(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the full interactive MFA flow during reauth."""
# 1. Set up the existing entry and trigger reauth
mock_config_entry.mock_state(hass, ConfigEntryState.LOADED)
hass.config.components.add(DOMAIN)
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"] == "reauth_confirm"
# 2. Test failure before MFA challenge (InvalidAuth)
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=InvalidAuth,
) as mock_login_fail_auth:
result_invalid_auth = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "bad-password",
},
)
mock_login_fail_auth.assert_awaited_once()
assert result_invalid_auth["type"] is FlowResultType.FORM
assert result_invalid_auth["step_id"] == "reauth_confirm"
assert result_invalid_auth["errors"] == {"base": "invalid_auth"}
# 3. Test failure before MFA challenge (CannotConnect)
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=CannotConnect,
) as mock_login_fail_connect:
result_cannot_connect = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "new-password",
},
)
mock_login_fail_connect.assert_awaited_once()
assert result_cannot_connect["type"] is FlowResultType.FORM
assert result_cannot_connect["step_id"] == "reauth_confirm"
assert result_cannot_connect["errors"] == {"base": "cannot_connect"}
# 4. Trigger the MfaChallenge on the next attempt
mock_mfa_handler = AsyncMock()
mock_mfa_handler.async_get_mfa_options.return_value = {
"Email": "fooxxx@mail.com",
"Phone": "xxx-123",
}
mock_mfa_handler.async_submit_mfa_code.return_value = {
"login_data_mock_key": "login_data_mock_value"
}
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=MfaChallenge(message="", handler=mock_mfa_handler),
) as mock_login_mfa:
result_mfa_challenge = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "new-password",
},
)
mock_login_mfa.assert_awaited_once()
# 5. Handle the happy path for the MFA flow
assert result_mfa_challenge["type"] is FlowResultType.FORM
assert result_mfa_challenge["step_id"] == "mfa_options"
mock_mfa_handler.async_get_mfa_options.assert_awaited_once()
result_mfa_code = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_method": "Phone"}
)
mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Phone")
assert result_mfa_code["type"] is FlowResultType.FORM
assert result_mfa_code["step_id"] == "mfa_code"
result_final = await hass.config_entries.flow.async_configure(
result["flow_id"], {"mfa_code": "good-code"}
)
mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("good-code")
# 6. Verify the reauth completes successfully
assert result_final["type"] is FlowResultType.ABORT
assert result_final["reason"] == "reauth_successful"
await hass.async_block_till_done()
# Check that data was updated and the entry was reloaded
assert mock_config_entry.data["password"] == "new-password"
assert mock_config_entry.data["login_data"] == {
"login_data_mock_key": "login_data_mock_value"
}
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1