"""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