diff --git a/homeassistant/components/monarchmoney/config_flow.py b/homeassistant/components/monarchmoney/config_flow.py index 5c25eeb761dadd..e2cd41ce266457 100644 --- a/homeassistant/components/monarchmoney/config_flow.py +++ b/homeassistant/components/monarchmoney/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any -from monarchmoney import LoginFailedException, MonarchMoney +from monarchmoney import LoginFailedException, MonarchMoney, RequireMFAException from monarchmoney.monarchmoney import SESSION_FILE import voluptuous as vol @@ -14,40 +14,59 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .const import CONF_MFA_SECRET, DOMAIN, LOGGER +from .const import CONF_MFA_CODE, DOMAIN, LOGGER _LOGGER = logging.getLogger(__name__) + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_MFA_SECRET): str, } ) +STEP_MFA_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_MFA_CODE): str, + } +) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + +async def validate_login( + hass: HomeAssistant, + data: dict[str, Any], + email: str | None = None, + password: str | None = None, +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Upon success a session will be saved """ - mfa_secret_key = data.get(CONF_MFA_SECRET, "") - email = data[CONF_EMAIL] - password = data[CONF_PASSWORD] - - # Test that we can login: + # mfa_secret_key = data.get(CONF_MFA_SECRET, "") + if not email: + email = data[CONF_EMAIL] + if not password: + password = data[CONF_PASSWORD] monarch_client = MonarchMoney() - try: - await monarch_client.login( - email=email, - password=password, - save_session=False, - use_saved_session=False, - mfa_secret_key=mfa_secret_key, - ) - except LoginFailedException as exc: - raise InvalidAuth from exc + if CONF_MFA_CODE in data: + mfa_code = data[CONF_MFA_CODE] + try: + await monarch_client.multi_factor_authenticate(email, password, mfa_code) + except LoginFailedException as err: + raise InvalidAuth from err + else: + try: + await monarch_client.login( + email=email, + password=password, + save_session=False, + use_saved_session=False, + ) + except RequireMFAException as err: + raise RequireMFAException from err + except LoginFailedException as err: + raise InvalidAuth from err # monarch_client.token LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") @@ -61,14 +80,31 @@ class MonarchMoneyConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self): + """Initialize config flow.""" + self.email: str | None = None + self.password: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: try: - info = await validate_input(self.hass, user_input) + info = await validate_login( + self.hass, user_input, email=self.email, password=self.password + ) + except RequireMFAException: + self.email = user_input[CONF_EMAIL] + self.password = user_input[CONF_PASSWORD] + + return self.async_show_form( + step_id="user", + data_schema=STEP_MFA_DATA_SCHEMA, + errors={"base": "mfa_required"}, + ) except InvalidAuth: errors["base"] = "invalid_auth" except Exception: @@ -84,5 +120,29 @@ async def async_step_user( ) +# +# async def old_async_step_user( +# self, user_input: dict[str, Any] | None = None +# ) -> ConfigFlowResult: +# """Handle the initial step.""" +# errors: dict[str, str] = {} +# if user_input is not None: +# try: +# info = await validate_input(self.hass, user_input) +# except InvalidAuth: +# errors["base"] = "invalid_auth" +# except Exception: +# _LOGGER.exception("Unexpected exception") +# errors["base"] = "unknown" +# else: +# return self.async_create_entry( +# title=info["title"], data={CONF_TOKEN: info[CONF_TOKEN]} +# ) +# +# return self.async_show_form( +# step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors +# ) + + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/monarchmoney/const.py b/homeassistant/components/monarchmoney/const.py index be408219d7641e..d15ac09d153e3f 100644 --- a/homeassistant/components/monarchmoney/const.py +++ b/homeassistant/components/monarchmoney/const.py @@ -7,3 +7,4 @@ LOGGER = logging.getLogger(__package__) CONF_MFA_SECRET = "mfa_secret" +CONF_MFA_CODE = "mfa_code" diff --git a/homeassistant/components/monarchmoney/strings.json b/homeassistant/components/monarchmoney/strings.json index 071af39d5295bb..05c51e2ea6706c 100644 --- a/homeassistant/components/monarchmoney/strings.json +++ b/homeassistant/components/monarchmoney/strings.json @@ -2,8 +2,10 @@ "config": { "step": { "user": { + "description": "Enter your Monarch Money email and password, if required you will also be prompted for your MFA code.", "data": { "mfa_secret": "Add your MFA Secret. See docs for help.", + "mfa_code": "Enter your MFA code", "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } @@ -12,7 +14,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "mfa_required": "Multi-factor authentication required." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/tests/components/monarchmoney/conftest.py b/tests/components/monarchmoney/conftest.py index a6ac2367daaa45..47087e616ffcbc 100644 --- a/tests/components/monarchmoney/conftest.py +++ b/tests/components/monarchmoney/conftest.py @@ -48,6 +48,7 @@ def mock_config_api() -> Generator[AsyncMock]: instance = mock_class.return_value type(instance).token = PropertyMock(return_value="mocked_token") instance.login = AsyncMock(return_value=None) + instance.multi_factor_authenticate = AsyncMock(return_value=None) instance.get_subscription_details = AsyncMock( return_value={ "subscription": { diff --git a/tests/components/monarchmoney/test_config_flow.py b/tests/components/monarchmoney/test_config_flow.py index 47b238d49c21a9..4e130a7814e9fd 100644 --- a/tests/components/monarchmoney/test_config_flow.py +++ b/tests/components/monarchmoney/test_config_flow.py @@ -1,22 +1,44 @@ """Test the Monarch Money config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock + +from monarchmoney import LoginFailedException, RequireMFAException from homeassistant import config_entries -from homeassistant.components.monarchmoney.config_flow import InvalidAuth -from homeassistant.components.monarchmoney.const import CONF_MFA_SECRET, DOMAIN -from homeassistant.const import ( - CONF_EMAIL, - CONF_HOST, - CONF_PASSWORD, - CONF_TOKEN, - CONF_USERNAME, -) +from homeassistant.components.monarchmoney.const import CONF_MFA_CODE, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_form( +async def test_form_simple( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock ) -> None: """Test we get the form.""" @@ -26,12 +48,71 @@ async def test_form( assert result["type"] == FlowResultType.FORM assert result["errors"] == {} + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = LoginFailedException("Invalid Auth") + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_EMAIL: "test-username", CONF_PASSWORD: "test-password", - CONF_MFA_SECRET: "test-mfa", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_config_api.return_value.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_mfa( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = RequireMFAException("mfa_required") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "mfa_required"} + assert result["step_id"] == "user" + + # Clear the mock now + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MFA_CODE: "123456", }, ) await hass.async_block_till_done() @@ -73,57 +154,57 @@ async def test_form( # # } # assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.monarchmoney.config_flow.PlaceholderHub.authenticate", - side_effect=InvalidAuth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - # Make sure the config flow tests finish with either an - # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so - # we can show the config flow is able to recover from an error. - with patch( - "homeassistant.components.monarchmoney.config_flow.PlaceholderHub.authenticate", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Name of the device" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 +# +# +# async def test_form_invalid_auth( +# hass: HomeAssistant, mock_setup_entry: AsyncMock +# ) -> None: +# """Test we handle invalid auth.""" +# result = await hass.config_entries.flow.async_init( +# DOMAIN, context={"source": config_entries.SOURCE_USER} +# ) +# +# with patch( +# "homeassistant.components.monarchmoney.config_flow.PlaceholderHub.authenticate", +# side_effect=InvalidAuth, +# ): +# result = await hass.config_entries.flow.async_configure( +# result["flow_id"], +# { +# CONF_HOST: "1.1.1.1", +# CONF_USERNAME: "test-username", +# CONF_PASSWORD: "test-password", +# }, +# ) +# +# assert result["type"] == FlowResultType.FORM +# assert result["errors"] == {"base": "invalid_auth"} +# +# # Make sure the config flow tests finish with either an +# # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so +# # we can show the config flow is able to recover from an error. +# with patch( +# "homeassistant.components.monarchmoney.config_flow.PlaceholderHub.authenticate", +# return_value=True, +# ): +# result = await hass.config_entries.flow.async_configure( +# result["flow_id"], +# { +# CONF_HOST: "1.1.1.1", +# CONF_USERNAME: "test-username", +# CONF_PASSWORD: "test-password", +# }, +# ) +# await hass.async_block_till_done() +# +# assert result["type"] == FlowResultType.CREATE_ENTRY +# assert result["title"] == "Name of the device" +# assert result["data"] == { +# CONF_HOST: "1.1.1.1", +# CONF_USERNAME: "test-username", +# CONF_PASSWORD: "test-password", +# } +# assert len(mock_setup_entry.mock_calls) == 1 # async def test_form_cannot_connect(