Implement bridge-backed Home Assistant integration

This commit is contained in:
2026-09-04 12:57:32 +02:00
parent 42050010b2
commit 9766f7f9b2
25 changed files with 2154 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
"""Shared fixtures for MB-Secure integration tests."""
import pytest
pytest_plugins = "pytest_homeassistant_custom_component"
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
"""Enable loading the custom integration in every test."""
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the asynchronous neutral Bridge API client."""
import json
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient
from custom_components.mb_secure.api import (
BridgeAuthenticationError,
BridgeClient,
)
TOKEN = "x" * 32
async def test_client_authenticates_and_parses_additive_info(
aiohttp_client,
socket_enabled: None,
) -> None:
"""The client authenticates locally and ignores additive response fields."""
async def info(request: web.Request) -> web.Response:
assert request.headers["Authorization"] == f"Bearer {TOKEN}"
return web.json_response(
{
"bridge_id": "bridge-installation-1",
"bridge_version": "0.1.0",
"api_version": 1,
"connected": True,
"capabilities": ["snapshot", "events"],
"future_field": True,
}
)
app = web.Application()
app.router.add_get("/v1/info", info)
http_client: TestClient = await aiohttp_client(app)
client = BridgeClient(
http_client.session,
host=str(http_client.make_url("/")),
port=80,
token=TOKEN,
)
result = await client.async_get_info()
assert result.bridge_id == "bridge-installation-1"
assert result.connected
async def test_client_maps_authentication_failures(
aiohttp_client,
socket_enabled: None,
) -> None:
"""HTTP authentication failures use the dedicated client exception."""
async def unauthorized(request: web.Request) -> web.Response:
return web.json_response({}, status=401)
app = web.Application()
app.router.add_get("/v1/info", unauthorized)
http_client: TestClient = await aiohttp_client(app)
client = BridgeClient(
http_client.session,
host=str(http_client.make_url("/")),
port=80,
token=TOKEN,
)
with pytest.raises(BridgeAuthenticationError):
await client.async_get_info()
async def test_client_parses_server_sent_domain_events(
aiohttp_client,
socket_enabled: None,
) -> None:
"""The event client accepts the mock's server-sent event framing."""
event_payload = {
"revision": 2,
"event_type": "state_changed",
"object_type": "area",
"object_id": "area-1",
"state": "armed_away",
}
async def events(request: web.Request) -> web.StreamResponse:
response = web.StreamResponse(
status=200,
headers={"Content-Type": "text/event-stream"},
)
await response.prepare(request)
await response.write(
f"event: domain_event\ndata: {json.dumps(event_payload)}\n\n".encode()
)
await response.write_eof()
return response
app = web.Application()
app.router.add_get("/v1/events", events)
http_client: TestClient = await aiohttp_client(app)
client = BridgeClient(
http_client.session,
host=str(http_client.make_url("/")),
port=80,
token=TOKEN,
)
stream = client.async_events()
event = await anext(stream)
await stream.aclose()
assert event.object_id == "area-1"
assert event.revision == 2
+148
View File
@@ -0,0 +1,148 @@
"""Tests for the MB-Secure config flow."""
from unittest.mock import patch
from homeassistant.config_entries import SOURCE_HASSIO, 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 custom_components.mb_secure.api import BridgeAuthenticationError
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
from custom_components.mb_secure.models import BridgeInfo
USER_INPUT = {
CONF_HOST: "bridge.local",
CONF_PORT: 8099,
CONF_TOKEN: "x" * 32,
}
async def test_user_flow_creates_entry_with_stable_bridge_id(
hass: HomeAssistant,
) -> None:
"""Successful validation uses the installation ID as config entry ID."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
info = BridgeInfo(
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=False,
capabilities=frozenset({"snapshot", "events"}),
)
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"], USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == USER_INPUT
assert result["result"].unique_id == "bridge-installation-1"
async def test_user_flow_reports_invalid_token(hass: HomeAssistant) -> None:
"""A rejected local bridge token remains inside the form flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"custom_components.mb_secure.api.BridgeClient.async_get_info",
side_effect=BridgeAuthenticationError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
async def test_user_flow_rejects_bridge_without_stable_id(
hass: HomeAssistant,
) -> None:
"""A bridge without an installation ID cannot create stable entities."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
info = BridgeInfo(
bridge_id=None,
bridge_version="0.1.0",
api_version=1,
connected=False,
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"], USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "missing_bridge_id"}
async def test_hassio_discovery_requires_confirmation_and_uses_bridge_id(
hass: HomeAssistant,
) -> None:
"""Supervisor discovery remains user-confirmed and adopts the bridge ID."""
discovery = HassioServiceInfo(
config=USER_INPUT,
name="MB-Secure Bridge",
slug="mb_secure_bridge",
uuid="discovery-installation-1",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_HASSIO},
data=discovery,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "hassio_confirm"
info = BridgeInfo(
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset({"snapshot", "events"}),
)
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"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "bridge-installation-1"
assert result["data"] == USER_INPUT
async def test_hassio_discovery_rejects_incomplete_data(
hass: HomeAssistant,
) -> None:
"""Supervisor discovery never guesses missing connection credentials."""
discovery = HassioServiceInfo(
config={"host": "bridge.local"},
name="MB-Secure Bridge",
slug="mb_secure_bridge",
uuid="discovery-installation-1",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_HASSIO},
data=discovery,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery"
+147
View File
@@ -0,0 +1,147 @@
"""Tests for MB-Secure snapshot and event coordination."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from homeassistant.core import HomeAssistant
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.const import CONF_TOKEN, DOMAIN
from custom_components.mb_secure.coordinator import MBSecureCoordinator
from custom_components.mb_secure.models import BridgeInfo, BridgeSnapshot, DomainEvent
def snapshot(state: str = "disarmed", revision: int = 1) -> BridgeSnapshot:
"""Return a minimal area snapshot."""
return BridgeSnapshot.from_dict(
{
"revision": revision,
"controllers": [
{
"id": "controller-1",
"name": "Controller",
"state": "online",
"capabilities": [],
}
],
"areas": [
{
"id": "area-1",
"controller_id": "controller-1",
"name": "Area",
"state": state,
"capabilities": ["arm_away"],
}
],
"points": [],
"modules": [],
"outputs": [],
}
)
def coordinator(
hass: HomeAssistant, client: MagicMock
) -> tuple[MBSecureCoordinator, MockConfigEntry]:
"""Create a coordinator and attached mock config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={"host": "bridge.local", "port": 8099, CONF_TOKEN: "x" * 32},
unique_id="bridge-installation-1",
)
entry.add_to_hass(hass)
return MBSecureCoordinator(hass, entry, client), entry
async def test_snapshot_authentication_failure_starts_reauth(
hass: HomeAssistant,
) -> None:
"""Rejected bridge credentials are converted to Home Assistant reauth."""
client = MagicMock()
client.async_get_info = AsyncMock(side_effect=BridgeAuthenticationError)
instance, _ = coordinator(hass, client)
with pytest.raises(ConfigEntryAuthFailed):
await instance._async_update_data()
async def test_snapshot_rejects_changed_bridge_identity(
hass: HomeAssistant,
) -> None:
"""An endpoint cannot silently replace the configured bridge instance."""
client = MagicMock()
client.async_get_info = AsyncMock(
return_value=BridgeInfo(
bridge_id="different-installation",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset(),
)
)
client.async_get_snapshot = AsyncMock(return_value=snapshot())
instance, _ = coordinator(hass, client)
with pytest.raises(UpdateFailed, match="bridge installation ID has changed"):
await instance._async_update_data()
client.async_get_snapshot.assert_not_awaited()
async def test_contiguous_event_updates_coordinator_data(
hass: HomeAssistant,
) -> None:
"""A contiguous event updates state without waiting for the next poll."""
client = MagicMock()
async def events():
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()
await instance._async_event_loop()
assert instance.data.revision == 2
assert instance.data.areas["area-1"].state == "armed_away"
entry.async_start_reauth.assert_called_once_with(hass)
async def test_event_gap_requests_snapshot_reconciliation(
hass: HomeAssistant,
) -> None:
"""A revision gap schedules a full snapshot refresh."""
client = MagicMock()
async def events():
yield DomainEvent(
revision=3,
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())
instance.async_request_refresh = AsyncMock()
entry.async_start_reauth = MagicMock()
await instance._async_event_loop()
instance.async_request_refresh.assert_awaited_once()
assert instance.data.revision == 1
+42
View File
@@ -0,0 +1,42 @@
"""Tests for MB-Secure diagnostics redaction."""
from unittest.mock import AsyncMock, MagicMock
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
from custom_components.mb_secure.diagnostics import async_get_config_entry_diagnostics
async def test_diagnostics_redact_config_and_whitelist_bridge_data(
hass: HomeAssistant,
) -> None:
"""Tokens, hosts, and unrecognized bridge fields never leave diagnostics."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "private-host.local",
"port": 8099,
CONF_TOKEN: "x" * 32,
},
unique_id="bridge-installation-1",
)
coordinator = MagicMock()
coordinator.client.async_get_diagnostics = AsyncMock(
return_value={
"api_version": 1,
"connected": True,
"snapshot_revision": 4,
"object_counts": {"areas": 1},
"unexpected_private_field": "must-not-leak",
}
)
entry.runtime_data = coordinator
result = await async_get_config_entry_diagnostics(hass, entry)
assert result["config_entry"]["host"] != "private-host.local"
assert result["config_entry"][CONF_TOKEN] != entry.data[CONF_TOKEN]
assert result["config_entry"]["port"] == 8099
assert "unexpected_private_field" not in result["bridge"]
+192
View File
@@ -0,0 +1,192 @@
"""Tests for MB-Secure devices and entities."""
from copy import deepcopy
from unittest.mock import patch
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
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
ENTRY_DATA = {
"host": "bridge.local",
"port": 8099,
CONF_TOKEN: "x" * 32,
}
def snapshot_payload() -> dict[str, object]:
"""Return a capability-complete normalized test snapshot."""
return {
"revision": 1,
"controllers": [
{
"id": "controller-1",
"name": "Main Controller",
"state": "online",
"capabilities": ["snapshot"],
}
],
"areas": [
{
"id": "area-1",
"controller_id": "controller-1",
"name": "Ground Floor",
"state": "disarmed",
"capabilities": ["arm_away", "disarm"],
}
],
"points": [
{
"id": "point-1",
"controller_id": "controller-1",
"area_id": "area-1",
"module_id": None,
"name": "Front Door",
"state": "closed",
"capabilities": ["binary_state"],
},
{
"id": "point-without-capability",
"controller_id": "controller-1",
"area_id": "area-1",
"module_id": None,
"name": "Unsupported Point",
"state": "unknown",
"capabilities": [],
},
],
"modules": [
{
"id": "module-1",
"controller_id": "controller-1",
"name": "Expansion Module",
"state": "online",
"capabilities": ["status"],
}
],
"outputs": [
{
"id": "output-1",
"controller_id": "controller-1",
"module_id": "module-1",
"name": "Indicator",
"state": "off",
"capabilities": ["set"],
}
],
}
async def setup_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Set up an entry against mocked neutral Bridge API responses."""
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id="bridge-installation-1",
)
entry.add_to_hass(hass)
info = BridgeInfo(
bridge_id="bridge-installation-1",
bridge_version="0.1.0",
api_version=1,
connected=True,
capabilities=frozenset({"snapshot", "events", "commands"}),
)
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
with (
patch(
"custom_components.mb_secure.api.BridgeClient.async_get_info",
return_value=info,
),
patch(
"custom_components.mb_secure.api.BridgeClient.async_get_snapshot",
return_value=snapshot,
),
patch.object(MBSecureCoordinator, "async_start_event_listener"),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def test_setup_creates_capability_gated_entities_and_hierarchy(
hass: HomeAssistant,
) -> None:
"""Only reported capabilities create stable entities and linked devices."""
entry = await setup_entry(hass)
registry = er.async_get(hass)
unique_ids = {
entity.unique_id
for entity in er.async_entries_for_config_entry(registry, entry.entry_id)
}
assert unique_ids == {
"bridge-installation-1:areas:area-1",
"bridge-installation-1:controllers:controller-1",
"bridge-installation-1:modules:module-1",
"bridge-installation-1:outputs:output-1",
"bridge-installation-1:points:point-1",
}
device_registry = dr.async_get(hass)
controller = device_registry.async_get_device_by_identifier(
(DOMAIN, "bridge-installation-1:controller:controller-1"),
entry.entry_id,
)
area = device_registry.async_get_device_by_identifier(
(DOMAIN, "bridge-installation-1:area:area-1"),
entry.entry_id,
)
module = device_registry.async_get_device_by_identifier(
(DOMAIN, "bridge-installation-1:module:module-1"),
entry.entry_id,
)
assert controller is not None
assert area is not None and area.via_device_id == controller.id
assert module is not None and module.via_device_id == controller.id
async def test_snapshot_topology_changes_add_and_remove_entities(
hass: HomeAssistant,
) -> None:
"""Snapshot reconciliation updates the runtime entity topology."""
entry = await setup_entry(hass)
coordinator = entry.runtime_data
registry = er.async_get(hass)
old_entry = registry.async_get_entity_id(
"binary_sensor",
DOMAIN,
"bridge-installation-1:points:point-1",
)
assert old_entry is not None
payload = deepcopy(snapshot_payload())
payload["revision"] = 2
points = payload["points"]
assert isinstance(points, list)
points.pop(0)
points.append(
{
"id": "point-2",
"controller_id": "controller-1",
"area_id": "area-1",
"module_id": None,
"name": "Back Door",
"state": "open",
"capabilities": ["binary_state"],
}
)
coordinator.async_set_updated_data(BridgeSnapshot.from_dict(payload))
await hass.async_block_till_done()
unique_ids = {
entity.unique_id
for entity in er.async_entries_for_config_entry(registry, entry.entry_id)
}
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
+143
View File
@@ -0,0 +1,143 @@
"""Tests for vendor-neutral Bridge API v1 models."""
from typing import Any
import pytest
from custom_components.mb_secure.models import (
BridgeDataError,
BridgeInfo,
BridgeSnapshot,
DomainEvent,
)
def test_bridge_info_accepts_stable_bridge_id() -> None:
"""Bridge metadata accepts the installation identifier used by config flow."""
info = BridgeInfo.from_dict(
{
"bridge_id": "bridge-1",
"bridge_version": "0.1.0",
"api_version": 1,
"connected": False,
"capabilities": ["snapshot", "events"],
"future_field": "ignored",
}
)
assert info.bridge_id == "bridge-1"
def test_bridge_info_tolerates_legacy_v1_without_bridge_id() -> None:
"""A newer client remains compatible with the original API v1 response."""
info = BridgeInfo.from_dict(
{
"bridge_version": "0.1.0",
"api_version": 1,
"connected": False,
"capabilities": [],
}
)
assert info.bridge_id is None
def snapshot_payload() -> dict[str, Any]:
"""Return a minimal neutral snapshot."""
return {
"revision": 4,
"controllers": [
{
"id": "controller-1",
"name": "Controller",
"state": "online",
"capabilities": [],
"future_field": True,
}
],
"areas": [
{
"id": "area-1",
"controller_id": "controller-1",
"name": "Area",
"state": "disarmed",
"capabilities": ["arm_away", "disarm"],
}
],
"points": [],
"modules": [],
"outputs": [],
"future_collection": [],
}
def test_snapshot_tolerates_additive_fields() -> None:
"""API v1 parsers ignore fields added in compatible updates."""
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
assert snapshot.controllers["controller-1"].name == "Controller"
assert snapshot.revision == 4
def test_snapshot_accepts_empty_display_name_allowed_by_v1() -> None:
"""Display names are not identifiers and may be empty in API v1."""
payload = snapshot_payload()
payload["controllers"][0]["name"] = ""
snapshot = BridgeSnapshot.from_dict(payload)
assert snapshot.controllers["controller-1"].name == ""
def test_snapshot_rejects_duplicate_stable_ids() -> None:
"""Duplicate technical IDs cannot create ambiguous entities."""
payload = snapshot_payload()
payload["controllers"].append(dict(payload["controllers"][0]))
with pytest.raises(BridgeDataError):
BridgeSnapshot.from_dict(payload)
def test_snapshot_rejects_unknown_parent_reference() -> None:
"""Objects cannot reference a controller absent from the snapshot."""
payload = snapshot_payload()
payload["areas"][0]["controller_id"] = "missing-controller"
with pytest.raises(BridgeDataError):
BridgeSnapshot.from_dict(payload)
def test_contiguous_state_event_is_applied() -> None:
"""A contiguous state event updates one object and the revision."""
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
event = DomainEvent.from_dict(
{
"revision": 5,
"event_type": "state_changed",
"object_type": "area",
"object_id": "area-1",
"state": "armed_away",
}
)
updated = snapshot.apply_event(event)
assert updated is not None
assert updated.revision == 5
assert updated.areas["area-1"].state == "armed_away"
def test_event_gap_requires_snapshot_reconciliation() -> None:
"""A missing event is not applied over an incomplete state."""
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
event = DomainEvent.from_dict(
{
"revision": 6,
"event_type": "state_changed",
"object_type": "area",
"object_id": "area-1",
"state": "armed_away",
}
)
assert snapshot.apply_event(event) is None