Harden bridge state and reauthentication handling

This commit is contained in:
2026-09-04 13:07:21 +02:00
parent 9766f7f9b2
commit 1d51e56272
9 changed files with 264 additions and 12 deletions
+73 -1
View File
@@ -2,11 +2,12 @@
from unittest.mock import patch
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.mb_secure.api import BridgeAuthenticationError
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
@@ -146,3 +147,74 @@ async def test_hassio_discovery_rejects_incomplete_data(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery"
async def test_reauth_updates_token_for_same_bridge(hass: HomeAssistant) -> None:
"""A replacement token is accepted only for the configured bridge."""
entry = MockConfigEntry(
domain=DOMAIN,
data=USER_INPUT,
unique_id="bridge-installation-1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id},
data=entry.data,
)
info = BridgeInfo(
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset(),
)
new_token = "y" * 32
with patch(
"custom_components.mb_secure.api.BridgeClient.async_get_info",
return_value=info,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_TOKEN: new_token}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_TOKEN] == new_token
async def test_reauth_rejects_token_for_different_bridge(
hass: HomeAssistant,
) -> None:
"""A valid token from another installation cannot replace the token."""
entry = MockConfigEntry(
domain=DOMAIN,
data=USER_INPUT,
unique_id="bridge-installation-1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id},
data=entry.data,
)
info = BridgeInfo(
bridge_id="bridge-installation-2",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset(),
)
with patch(
"custom_components.mb_secure.api.BridgeClient.async_get_info",
return_value=info,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_TOKEN: "y" * 32}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_account"
assert entry.data[CONF_TOKEN] == USER_INPUT[CONF_TOKEN]
+64 -2
View File
@@ -1,6 +1,6 @@
"""Tests for MB-Secure snapshot and event coordination."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.core import HomeAssistant
@@ -8,7 +8,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import UpdateFailed
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.mb_secure.api import BridgeAuthenticationError
from custom_components.mb_secure.api import (
BridgeAuthenticationError,
BridgeConnectionError,
)
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
from custom_components.mb_secure.coordinator import MBSecureCoordinator
from custom_components.mb_secure.models import BridgeInfo, BridgeSnapshot, DomainEvent
@@ -91,6 +94,65 @@ async def test_snapshot_rejects_changed_bridge_identity(
client.async_get_snapshot.assert_not_awaited()
async def test_bridge_restart_accepts_a_new_snapshot_revision(
hass: HomeAssistant,
) -> None:
"""A full snapshot reconciles revision reset after a bridge restart."""
client = MagicMock()
client.async_get_info = AsyncMock(
return_value=BridgeInfo(
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset(),
)
)
client.async_get_snapshot = AsyncMock(return_value=snapshot(revision=0))
instance, _ = coordinator(hass, client)
instance.async_set_updated_data(snapshot(revision=20))
result = await instance._async_update_data()
assert result.revision == 0
async def test_event_stream_reconnects_after_connection_loss(
hass: HomeAssistant,
) -> None:
"""The event listener reconnects and resumes contiguous updates."""
client = MagicMock()
attempts = 0
async def events():
nonlocal attempts
attempts += 1
if attempts == 1:
if False:
yield
raise BridgeConnectionError
yield DomainEvent(
revision=2,
event_type="state_changed",
object_type="area",
object_id="area-1",
state="armed_away",
)
raise BridgeAuthenticationError
client.async_events = events
instance, entry = coordinator(hass, client)
instance.async_set_updated_data(snapshot())
entry.async_start_reauth = MagicMock()
with patch("custom_components.mb_secure.coordinator.asyncio.sleep") as sleep:
await instance._async_event_loop()
sleep.assert_awaited_once_with(1)
assert attempts == 2
assert instance.data.areas["area-1"].state == "armed_away"
async def test_contiguous_event_updates_coordinator_data(
hass: HomeAssistant,
) -> None:
+96 -4
View File
@@ -1,7 +1,7 @@
"""Tests for MB-Secure devices and entities."""
from copy import deepcopy
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -82,7 +82,12 @@ def snapshot_payload() -> dict[str, object]:
}
async def setup_entry(hass: HomeAssistant) -> MockConfigEntry:
async def setup_entry(
hass: HomeAssistant,
*,
connected: bool = True,
payload: dict[str, object] | None = None,
) -> MockConfigEntry:
"""Set up an entry against mocked neutral Bridge API responses."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -94,10 +99,10 @@ async def setup_entry(hass: HomeAssistant) -> MockConfigEntry:
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=True,
connected=connected,
capabilities=frozenset({"snapshot", "events", "commands"}),
)
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
snapshot = BridgeSnapshot.from_dict(payload or snapshot_payload())
with (
patch(
"custom_components.mb_secure.api.BridgeClient.async_get_info",
@@ -190,3 +195,90 @@ async def test_snapshot_topology_changes_add_and_remove_entities(
assert "bridge-installation-1:points:point-1" not in unique_ids
assert "bridge-installation-1:points:point-2" in unique_ids
assert hass.states.get(old_entry) is None
async def test_panel_connection_loss_marks_all_entities_unavailable(
hass: HomeAssistant,
) -> None:
"""A reachable bridge does not hide loss of its panel connection."""
entry = await setup_entry(hass, connected=False)
registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
state = hass.states.get(entity.entity_id)
assert state is not None
assert state.state == "unavailable"
async def test_unknown_binary_states_remain_unknown(hass: HomeAssistant) -> None:
"""Unknown neutral states are never converted into a false clear state."""
payload = snapshot_payload()
points = payload["points"]
outputs = payload["outputs"]
assert isinstance(points, list) and isinstance(outputs, list)
points[0]["state"] = "future_state"
outputs[0]["state"] = "future_state"
entry = await setup_entry(hass, payload=payload)
registry = er.async_get(hass)
for unique_id in (
"bridge-installation-1:points:point-1",
"bridge-installation-1:outputs:output-1",
):
entity_id = next(
entity.entity_id
for entity in er.async_entries_for_config_entry(registry, entry.entry_id)
if entity.unique_id == unique_id
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "unknown"
async def test_entity_services_use_scoped_bridge_commands(
hass: HomeAssistant,
) -> None:
"""Entity services send only normalized commands for their own object IDs."""
entry = await setup_entry(hass)
coordinator = entry.runtime_data
coordinator.client.async_arm_area = AsyncMock()
coordinator.client.async_disarm_area = AsyncMock()
coordinator.client.async_set_output = AsyncMock()
coordinator.async_request_refresh = AsyncMock()
registry = er.async_get(hass)
area_entity_id = registry.async_get_entity_id(
"alarm_control_panel",
DOMAIN,
"bridge-installation-1:areas:area-1",
)
output_entity_id = registry.async_get_entity_id(
"switch",
DOMAIN,
"bridge-installation-1:outputs:output-1",
)
assert area_entity_id is not None and output_entity_id is not None
await hass.services.async_call(
"alarm_control_panel",
"alarm_arm_away",
{"entity_id": area_entity_id},
blocking=True,
)
await hass.services.async_call(
"alarm_control_panel",
"alarm_disarm",
{"entity_id": area_entity_id},
blocking=True,
)
await hass.services.async_call(
"switch",
"turn_on",
{"entity_id": output_entity_id},
blocking=True,
)
coordinator.client.async_arm_area.assert_awaited_once_with("area-1", "away")
coordinator.client.async_disarm_area.assert_awaited_once_with("area-1")
coordinator.client.async_set_output.assert_awaited_once_with("output-1", "on")
assert coordinator.async_request_refresh.await_count == 3