Add re-authentication to BSBLan (#146280)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
Willem-Jan van Rootselaar 2025-08-01 16:42:59 +02:00 committed by GitHub
parent fb2d62d692
commit b4a4e218ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 577 additions and 43 deletions

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from bsblan import BSBLAN, BSBLANConfig, BSBLANError from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -45,7 +46,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME) self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD) self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create() return await self._validate_and_create(user_input)
async def async_step_zeroconf( async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
@ -128,14 +129,29 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME) self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD) self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(is_discovery=True) return await self._validate_and_create(user_input, is_discovery=True)
async def _validate_and_create( async def _validate_and_create(
self, is_discovery: bool = False self, user_input: dict[str, Any], is_discovery: bool = False
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Validate device connection and create entry.""" """Validate device connection and create entry."""
try: try:
await self._get_bsblan_info(is_discovery=is_discovery) await self._get_bsblan_info()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors={"base": "invalid_auth"},
description_placeholders={"host": str(self.host)},
)
return self._show_setup_form({"base": "invalid_auth"}, user_input)
except BSBLANError: except BSBLANError:
if is_discovery: if is_discovery:
return self.async_show_form( return self.async_show_form(
@ -154,18 +170,145 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry() return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation flow."""
existing_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert existing_entry
if user_input is None:
# Preserve existing values as defaults
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
)
# Use existing host and port, update auth credentials
self.host = existing_entry.data[CONF_HOST]
self.port = existing_entry.data[CONF_PORT]
self.passkey = user_input.get(CONF_PASSKEY) or existing_entry.data.get(
CONF_PASSKEY
)
self.username = user_input.get(CONF_USERNAME) or existing_entry.data.get(
CONF_USERNAME
)
self.password = user_input.get(CONF_PASSWORD)
try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
except BSBLANError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
# Update the config entry with new auth data
data_updates = {}
if self.passkey is not None:
data_updates[CONF_PASSKEY] = self.passkey
if self.username is not None:
data_updates[CONF_USERNAME] = self.username
if self.password is not None:
data_updates[CONF_PASSWORD] = self.password
return self.async_update_reload_and_abort(
existing_entry, data_updates=data_updates, reason="reauth_successful"
)
@callback @callback
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: def _show_setup_form(
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the setup form to the user.""" """Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_HOST): str, vol.Required(
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
vol.Optional(CONF_PASSKEY): str, ): str,
vol.Optional(CONF_USERNAME): str, vol.Optional(
vol.Optional(CONF_PASSWORD): str, CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
} }
), ),
errors=errors or {}, errors=errors or {},
@ -186,7 +329,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
) )
async def _get_bsblan_info( async def _get_bsblan_info(
self, raise_on_progress: bool = True, is_discovery: bool = False self,
raise_on_progress: bool = True,
is_reauth: bool = False,
) -> None: ) -> None:
"""Get device information from a BSBLAN device.""" """Get device information from a BSBLAN device."""
config = BSBLANConfig( config = BSBLANConfig(
@ -209,11 +354,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
format_mac(self.mac), raise_on_progress=raise_on_progress format_mac(self.mac), raise_on_progress=raise_on_progress
) )
# Always allow updating host/port for both user and discovery flows # Skip unique_id configuration check during reauth to prevent "already_configured" abort
# This ensures connectivity is maintained when devices change IP addresses if not is_reauth:
self._abort_if_unique_id_configured( # Always allow updating host/port for both user and discovery flows
updates={ # This ensures connectivity is maintained when devices change IP addresses
CONF_HOST: self.host, self._abort_if_unique_id_configured(
CONF_PORT: self.port, updates={
} CONF_HOST: self.host,
) CONF_PORT: self.port,
}
)

View File

@ -4,11 +4,19 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from random import randint from random import randint
from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
HotWaterState,
Sensor,
State,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state() state = await self.client.state()
sensor = await self.client.sensor() sensor = await self.client.sensor()
dhw = await self.client.hot_water_state() dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
) from err
except BSBLANConnectionError as err: except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed( raise UpdateFailed(

View File

@ -33,14 +33,25 @@
"username": "[%key:component::bsblan::config::step::user::data_description::username%]", "username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]" "password": "[%key:component::bsblan::config::step::user::data_description::password%]"
} }
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"data": {
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
} }
}, },
"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%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"exceptions": { "exceptions": {

View File

@ -3,11 +3,11 @@
from ipaddress import ip_address from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from bsblan import BSBLANConnectionError from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
import pytest import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -129,7 +129,7 @@ async def test_full_user_flow_implementation(
result = await _init_user_flow(hass) result = await _init_user_flow(hass)
_assert_form_result(result, "user") _assert_form_result(result, "user")
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -142,7 +142,7 @@ async def test_full_user_flow_implementation(
) )
_assert_create_entry_result( _assert_create_entry_result(
result2, result,
format_mac("00:80:41:19:69:90"), format_mac("00:80:41:19:69:90"),
{ {
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
@ -185,6 +185,94 @@ async def test_connection_error(
_assert_form_result(result, "user", {"base": "cannot_connect"}) _assert_form_result(result, "user", {"base": "cannot_connect"})
async def test_authentication_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test we show user form on BSBLan authentication error with field preservation."""
mock_bsblan.device.side_effect = BSBLANAuthError
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PORT: 8080,
CONF_PASSKEY: "secret",
CONF_USERNAME: "testuser",
CONF_PASSWORD: "wrongpassword",
}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": "invalid_auth"}
assert result.get("step_id") == "user"
# Verify that user input is preserved in the form
data_schema = result.get("data_schema")
assert data_schema is not None
# Check that the form fields contain the previously entered values
host_field = next(
field for field in data_schema.schema if field.schema == CONF_HOST
)
port_field = next(
field for field in data_schema.schema if field.schema == CONF_PORT
)
passkey_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSKEY
)
username_field = next(
field for field in data_schema.schema if field.schema == CONF_USERNAME
)
password_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSWORD
)
# The defaults are callable functions, so we need to call them
assert host_field.default() == "192.168.1.100"
assert port_field.default() == 8080
assert passkey_field.default() == "secret"
assert username_field.default() == "testuser"
assert password_field.default() == "wrongpassword"
async def test_authentication_error_vs_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test that authentication and connection errors are handled differently."""
# Test connection error first
mock_bsblan.device.side_effect = BSBLANConnectionError
result = await _init_user_flow(
hass,
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
},
)
_assert_form_result(result, "user", {"base": "cannot_connect"})
# Reset and test authentication error
mock_bsblan.device.side_effect = BSBLANAuthError
result = await _init_user_flow(
hass,
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_USERNAME: "admin",
CONF_PASSWORD: "wrongpass",
},
)
_assert_form_result(result, "user", {"base": "invalid_auth"})
async def test_user_device_exists_abort( async def test_user_device_exists_abort(
hass: HomeAssistant, hass: HomeAssistant,
mock_bsblan: MagicMock, mock_bsblan: MagicMock,
@ -217,7 +305,7 @@ async def test_zeroconf_discovery(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm") _assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -228,7 +316,7 @@ async def test_zeroconf_discovery(
) )
_assert_create_entry_result( _assert_create_entry_result(
result2, result,
format_mac("00:80:41:19:69:90"), format_mac("00:80:41:19:69:90"),
{ {
CONF_HOST: "10.0.2.60", CONF_HOST: "10.0.2.60",
@ -285,7 +373,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth(
# Reset side_effect for the second call to succeed # Reset side_effect for the second call to succeed
mock_bsblan.device.side_effect = None mock_bsblan.device.side_effect = None
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -295,7 +383,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth(
) )
_assert_create_entry_result( _assert_create_entry_result(
result2, result,
"00:80:41:19:69:90", # MAC from fixture file "00:80:41:19:69:90", # MAC from fixture file
{ {
CONF_HOST: "10.0.2.60", CONF_HOST: "10.0.2.60",
@ -324,10 +412,10 @@ async def test_zeroconf_discovery_no_mac_no_auth_required(
_assert_form_result(result, "discovery_confirm") _assert_form_result(result, "discovery_confirm")
# User confirms the discovery # User confirms the discovery
result2 = await _configure_flow(hass, result["flow_id"], {}) result = await _configure_flow(hass, result["flow_id"], {})
_assert_create_entry_result( _assert_create_entry_result(
result2, result,
"00:80:41:19:69:90", # MAC from fixture file "00:80:41:19:69:90", # MAC from fixture file
{ {
CONF_HOST: "10.0.2.60", CONF_HOST: "10.0.2.60",
@ -355,7 +443,7 @@ async def test_zeroconf_discovery_connection_error(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm") _assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -365,7 +453,7 @@ async def test_zeroconf_discovery_connection_error(
}, },
) )
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"})
async def test_zeroconf_discovery_updates_host_port_on_existing_entry( async def test_zeroconf_discovery_updates_host_port_on_existing_entry(
@ -445,7 +533,7 @@ async def test_zeroconf_discovery_connection_error_recovery(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm") _assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -455,12 +543,12 @@ async def test_zeroconf_discovery_connection_error_recovery(
}, },
) )
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"})
# Second attempt succeeds (connection is fixed) # Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None mock_bsblan.device.side_effect = None
result3 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -471,7 +559,7 @@ async def test_zeroconf_discovery_connection_error_recovery(
) )
_assert_create_entry_result( _assert_create_entry_result(
result3, result,
format_mac("00:80:41:19:69:90"), format_mac("00:80:41:19:69:90"),
{ {
CONF_HOST: "10.0.2.60", CONF_HOST: "10.0.2.60",
@ -513,7 +601,7 @@ async def test_connection_error_recovery(
# Second attempt succeeds (connection is fixed) # Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None mock_bsblan.device.side_effect = None
result2 = await _configure_flow( result = await _configure_flow(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
@ -526,7 +614,7 @@ async def test_connection_error_recovery(
) )
_assert_create_entry_result( _assert_create_entry_result(
result2, result,
format_mac("00:80:41:19:69:90"), format_mac("00:80:41:19:69:90"),
{ {
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
@ -567,3 +655,249 @@ async def test_zeroconf_discovery_no_mac_duplicate_host_port(
# Should not call device API since we abort early # Should not call device API since we abort early
assert len(mock_bsblan.device.mock_calls) == 0 assert len(mock_bsblan.device.mock_calls) == 0
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reauth flow."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Check that the form has the correct description placeholder
assert result.get("description_placeholders") == {"name": "BSBLAN Setup"}
# Check that existing values are preserved as defaults
data_schema = result.get("data_schema")
assert data_schema is not None
# Complete reauth with new credentials
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "new_passkey",
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify config entry was updated with new credentials
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
# Verify host and port remain unchanged
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_config_entry.data[CONF_PORT] == 80
async def test_reauth_flow_auth_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with authentication error."""
mock_config_entry.add_to_hass(hass)
# Mock authentication error
mock_bsblan.device.side_effect = BSBLANAuthError
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit with wrong credentials
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "wrong_passkey",
CONF_USERNAME: "wrong_admin",
CONF_PASSWORD: "wrong_password",
},
)
_assert_form_result(result, "reauth_confirm", {"base": "invalid_auth"})
# Verify that user input is preserved in the form after error
data_schema = result.get("data_schema")
assert data_schema is not None
# Check that the form fields contain the previously entered values
passkey_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSKEY
)
username_field = next(
field for field in data_schema.schema if field.schema == CONF_USERNAME
)
assert passkey_field.default() == "wrong_passkey"
assert username_field.default() == "wrong_admin"
async def test_reauth_flow_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with connection error."""
mock_config_entry.add_to_hass(hass)
# Mock connection error
mock_bsblan.device.side_effect = BSBLANConnectionError
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit credentials but get connection error
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_form_result(result, "reauth_confirm", {"base": "cannot_connect"})
async def test_reauth_flow_preserves_existing_values(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that reauth flow preserves existing values when user doesn't change them."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit without changing any credentials (only password is provided)
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSWORD: "new_password_only",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify that existing passkey and username are preserved
assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original value
assert mock_config_entry.data[CONF_USERNAME] == "admin" # Original value
assert mock_config_entry.data[CONF_PASSWORD] == "new_password_only" # New value
async def test_reauth_flow_partial_credentials_update(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with partial credential updates."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
# Submit with only username and password changes
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify partial update: passkey preserved, username and password updated
assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original preserved
assert mock_config_entry.data[CONF_USERNAME] == "new_admin" # Updated
assert mock_config_entry.data[CONF_PASSWORD] == "new_password" # Updated
# Host and port should remain unchanged
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_config_entry.data[CONF_PORT] == 80
async def test_zeroconf_discovery_auth_error_during_confirm(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test authentication error during discovery_confirm step."""
# Remove MAC from discovery to force discovery_confirm step
zeroconf_discovery_info.properties.pop("mac", None)
# Setup device to require authentication during initial discovery
mock_bsblan.device.side_effect = BSBLANError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=zeroconf_discovery_info,
)
_assert_form_result(result, "discovery_confirm")
# Now setup auth error for the confirmation step
mock_bsblan.device.side_effect = BSBLANAuthError
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "wrong_key",
CONF_USERNAME: "admin",
CONF_PASSWORD: "wrong_password",
},
)
# Should show the discovery_confirm form again with auth error
_assert_form_result(result, "discovery_confirm", {"base": "invalid_auth"})

View File

@ -2,13 +2,14 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from bsblan import BSBLANConnectionError from bsblan import BSBLANAuthError, BSBLANConnectionError
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
async def test_load_unload_config_entry( async def test_load_unload_config_entry(
@ -45,3 +46,32 @@ async def test_config_entry_not_ready(
assert len(mock_bsblan.state.mock_calls) == 1 assert len(mock_bsblan.state.mock_calls) == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_auth_failed_triggers_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that BSBLANAuthError during coordinator update triggers reauth flow."""
# First, set up the integration successfully
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
# Mock BSBLANAuthError during next update
mock_bsblan.initialize.side_effect = BSBLANAuthError("Authentication failed")
# Advance time by the coordinator's update interval to trigger update
freezer.tick(delta=20) # Advance beyond the 12 second scan interval + random offset
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check that a reauth flow has been started
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id