22127e2a59
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
99 lines
3.8 KiB
Python
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
|