core/tests/components/opower/test_config_flow.py

678 lines
24 KiB
Python

"""Test the Opower config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from opower import CannotConnect, InvalidAuth, MfaChallenge
import pytest
from homeassistant import config_entries
from homeassistant.components.opower.const import DOMAIN
from homeassistant.components.recorder import Recorder
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry, get_schema_suggested_value
@pytest.fixture(autouse=True, name="mock_setup_entry")
def override_async_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.opower.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_unload_entry() -> Generator[AsyncMock]:
"""Mock unloading a config entry."""
with patch(
"homeassistant.components.opower.async_unload_entry",
return_value=True,
) as mock_unload_entry:
yield mock_unload_entry
async def test_form(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: 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"] is FlowResultType.FORM
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(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)"
assert result3["data"] == {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
assert mock_login.call_count == 1
async def test_form_with_totp(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we can configure a utility that accepts a TOTP secret."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Select utility
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Consolidated Edison (ConEd)"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "credentials"
# Enter credentials
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"totp_secret": "test-totp",
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "Consolidated Edison (ConEd) (test-username)"
assert result3["data"] == {
"utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "test-password",
"totp_secret": "test-totp",
}
assert len(mock_setup_entry.mock_calls) == 1
assert mock_login.call_count == 1
async def test_form_with_invalid_totp(
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we handle an invalid TOTP secret."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"utility": "Consolidated Edison (ConEd)"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "credentials"
# Enter invalid credentials
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=InvalidAuth,
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"totp_secret": "bad-totp",
},
)
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"base": "invalid_auth"}
assert result3["step_id"] == "credentials"
# Enter valid credentials
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "updated-password",
"totp_secret": "good-totp",
},
)
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "Consolidated Edison (ConEd) (test-username)"
assert result4["data"] == {
"utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "updated-password",
"totp_secret": "good-totp",
}
assert len(mock_setup_entry.mock_calls) == 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(
("api_exception", "expected_error"),
[
(InvalidAuth, "invalid_auth"),
(CannotConnect, "cannot_connect"),
],
)
async def test_form_exceptions(
recorder_mock: Recorder,
hass: HomeAssistant,
api_exception: Exception,
expected_error: str,
) -> None:
"""Test we handle exceptions."""
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)"},
)
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
side_effect=api_exception,
) as mock_login:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": expected_error}
# On error, the form should have the previous user input as suggested values.
data_schema = result2["data_schema"].schema
assert get_schema_suggested_value(data_schema, "username") == "test-username"
assert get_schema_suggested_value(data_schema, "password") == "test-password"
assert mock_login.call_count == 1
async def test_form_already_configured(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user input for config_entry that already exists."""
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)"},
)
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
assert mock_login.call_count == 0
async def test_form_not_already_configured(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user input for config_entry different than the existing one."""
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)"},
)
with patch(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username2",
"password": "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert (
result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username2)"
)
assert result2["data"] == {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username2",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 2
assert mock_login.call_count == 1
async def test_form_valid_reauth(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that we can handle a valid 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"
assert result["context"]["source"] == "reauth"
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(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-username", "password": "test-password2"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
await hass.async_block_till_done()
assert mock_config_entry.data == {
"utility": "Pacific Gas and Electric Company (PG&E)",
"username": "test-username",
"password": "test-password2",
}
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert mock_login.call_count == 1
async def test_form_valid_reauth_with_totp(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
) -> None:
"""Test that we can handle a valid reauth for a utility with TOTP."""
mock_config_entry = MockConfigEntry(
title="Consolidated Edison (ConEd) (test-username)",
domain=DOMAIN,
data={
"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)
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]
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(
"homeassistant.components.opower.config_flow.Opower.async_login",
) as mock_login:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password2",
"totp_secret": "test-totp",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
await hass.async_block_till_done()
assert mock_config_entry.data == {
"utility": "Consolidated Edison (ConEd)",
"username": "test-username",
"password": "test-password2",
"totp_secret": "test-totp",
}
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 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