Files
notify-bridge/packages/server/tests/test_home_assistant_commands.py
alexei.dolgolyov 22127e2a59 feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces:

Notifications (subscription):
- Long-lived WebSocket client (aiohttp ws_connect) with auth handshake,
  exponential-backoff reconnect, bounded event queue, and area-registry
  enrichment cached per (re)connect
- ServiceProvider ABC gains an optional `subscribe()` method for push-style
  providers; HomeAssistantServiceProvider uses it via a per-provider
  supervisor task started in the FastAPI lifespan
- 4 event types (state_changed, automation_triggered, call_service,
  event_fired), 4 default Jinja templates (en + ru), HA-specific
  tracker filters (entity_glob, domain_allowlist, exact entity ids)
- Extracted shared dispatch pipeline (api/webhooks.py → services/
  event_dispatch.py) so subscription and webhook ingest share the same
  event_log + deferred-dispatch + quiet-hours code path

Bot commands:
- /status, /entities [glob], /state <entity_id>, /areas
- Multi-command WS session so /status and /areas cost one handshake
- Sensitive-attribute blocklist (camera access_token, entity_picture, etc.)
  and 30-attribute cap to keep /state output safe and within Telegram's
  message size
- Error-message redaction strips URL userinfo before surfacing to chat

Frontend:
- HA descriptor with toggle ConfigField type (new) and tag-input filter
  mode for free-text glob/domain lists (new TagInput component)
- 15 command slots + 4 notification slots wired into the existing
  template-config UI
2026-05-13 14:31:56 +03:00

99 lines
3.8 KiB
Python

"""Unit tests for HA bot command helpers — Phase 2.
Focus on the security-sensitive bits the reviewer flagged: attribute
filtering, error-message redaction, and the sample-context shape that
flows through Jinja preview rendering.
"""
from __future__ import annotations
from notify_bridge_server.commands.home_assistant_handler import (
_filter_attributes,
_is_sensitive_attr,
_normalize_state,
)
def test_filter_attributes_drops_credential_keys() -> None:
"""HA camera entities expose an ``access_token`` attribute. The handler
MUST NOT surface it to the chat user via /state."""
raw = {
"friendly_name": "Front Camera",
"access_token": "real-camera-proxy-token",
"entity_picture": "/api/camera_proxy/...?token=abc",
"brightness": 200,
}
safe, hidden = _filter_attributes(raw)
assert "access_token" not in safe
# entity_picture contains 'token' substring → blocked.
assert "entity_picture" not in safe
# friendly_name is rendered as a top-level field, not iterated.
assert "friendly_name" not in safe
# brightness is a normal attribute, passes through.
assert safe["brightness"] == 200
assert hidden == 2
def test_filter_attributes_caps_count() -> None:
"""When an entity has dozens of attributes the renderer would overflow
Telegram's 4096-char message limit. Cap at 30 with overflow surfaced."""
raw = {f"attr_{i:03d}": i for i in range(50)}
safe, hidden = _filter_attributes(raw)
assert len(safe) == 30
assert hidden == 20
def test_is_sensitive_attr_case_insensitive() -> None:
"""Match should not depend on key casing — custom integrations are
inconsistent about capitalization."""
assert _is_sensitive_attr("Access_Token") is True
assert _is_sensitive_attr("API_KEY") is True
assert _is_sensitive_attr("password") is True
assert _is_sensitive_attr("brightness") is False
assert _is_sensitive_attr("color_mode") is False
def test_normalize_state_filters_attrs() -> None:
"""End-to-end: feed _normalize_state a malicious state row, verify the
output has redacted attributes + hidden_attr_count surfaced."""
state_row = {
"entity_id": "camera.front_door",
"state": "idle",
"attributes": {
"friendly_name": "Front Door Camera",
"access_token": "leaked",
"brand": "Reolink",
},
"last_changed": "2026-05-13T12:00:00+00:00",
"last_updated": "2026-05-13T12:00:00+00:00",
}
out = _normalize_state(state_row)
assert out["entity_id"] == "camera.front_door"
assert out["friendly_name"] == "Front Door Camera"
assert out["domain"] == "camera"
# Top-level fields preserved.
assert out["state"] == "idle"
# Attributes dict is filtered.
assert "access_token" not in out["attributes"]
assert out["attributes"].get("brand") == "Reolink"
# Hidden count reflects access_token (friendly_name is top-level, not redacted).
assert out["hidden_attr_count"] == 1
def test_normalize_state_handles_missing_attributes() -> None:
"""A state row with no attributes dict should not crash."""
out = _normalize_state({"entity_id": "sensor.x", "state": "1"})
assert out["attributes"] == {}
assert out["hidden_attr_count"] == 0
def test_redact_ha_message_strips_userinfo() -> None:
"""The Phase 1 redact helper is re-exported via the HA package and used
by /entities, /state, /areas before surfacing errors. Make sure the
re-export still works and the contract is what we expect."""
from notify_bridge_core.providers.home_assistant import redact_ha_message
msg = "Cannot connect to https://leak-token@homeassistant.local:8123/api/websocket"
out = redact_ha_message(msg)
assert "leak-token@" not in out
assert "homeassistant.local:8123" in out