diff --git a/custom_components/houseplan/support_package.py b/custom_components/houseplan/support_package.py index ac2264c3..765af46e 100644 --- a/custom_components/houseplan/support_package.py +++ b/custom_components/houseplan/support_package.py @@ -31,6 +31,7 @@ _LANGUAGES = frozenset({"en", "ru", "de", "fr"}) _BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"}) _REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"}) _REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"}) +_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"}) class SupportPackageError(ValueError): @@ -232,6 +233,23 @@ def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | N return None +def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None: + """Project the compatibility-tolerant badge config into safe scalar fields.""" + if not isinstance(value, dict): + return None + out: dict[str, Any] = {} + enabled = value.get("enabled") + if isinstance(enabled, bool): + out["enabled"] = enabled + position = value.get("position") + if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS: + out["position"] = position + source = _project_value_source(ids, value.get("source")) + if source is not None: + out["source"] = source + return out or None + + def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]: binding_kind, binding = _binding(ids, marker.get("binding")) out: dict[str, Any] = { @@ -264,12 +282,8 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]: projected = _project_value_source(ids, marker.get(key)) if projected: out[key] = projected - badge = marker.get("value_badge") - if isinstance(badge, dict): - safe_badge = _copy_keys(badge, ("enabled", "position")) - source = _project_value_source(ids, badge.get("source")) - if source: - safe_badge["source"] = source + safe_badge = _project_value_badge(ids, marker.get("value_badge")) + if safe_badge is not None: out["value_badge"] = safe_badge return out diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0da15443..70b0dabd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Support packages now type-check value-badge flags and positions while building + their privacy projection, so malformed nested configuration cannot be copied + verbatim into a report + ([#420](https://github.com/Matysh/houseplan-card/issues/420)). - Area relocation provenance now survives a temporarily empty or shortened Home Assistant registry: cleanup uses the full binding roster and requires two non-empty authoritative observations before forgetting a missing device diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index d30ee7c4..589df765 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- При построении privacy-проекции support-пакет теперь проверяет типы флага и + позиции бейджа значения, поэтому некорректный вложенный конфиг не может + дословно попасть в отчёт + ([#420](https://github.com/Matysh/houseplan-card/issues/420)). - Провенанс автоматического переноса между HA-зонами теперь переживает временно пустой или усечённый реестр Home Assistant: уборка сверяется с полным roster привязок и забывает исчезнувшее устройство лишь после двух непустых diff --git a/tests_backend/test_support_package.py b/tests_backend/test_support_package.py index 393af5b1..26892a3d 100644 --- a/tests_backend/test_support_package.py +++ b/tests_backend/test_support_package.py @@ -33,6 +33,7 @@ def _source() -> tuple[dict, dict, list[str]]: "area.private", "device-secret", "sensor.secret_temperature", "https://private.example/plan.png", "C:\\Users\\Private\\floor.png", "person@example.test", "private note", "unknown-private-value", + "value-badge-private-sentinel", ] config = { "model_version": 9, @@ -80,6 +81,12 @@ def _source() -> tuple[dict, dict, list[str]]: "link": "https://private.example/device", "description": "private note", "pdfs": [{"name": "manual", "url": "C:\\Users\\Private\\floor.png"}], "controls": ["sensor.secret_temperature"], + "value_badge": { + "enabled": {"private": "value-badge-private-sentinel"}, + "position": ["value-badge-private-sentinel"], + "source": {"kind": "private", "payload": "value-badge-private-sentinel"}, + "future": {"private": "value-badge-private-sentinel"}, + }, "unknown": "unknown-private-value", }], } @@ -301,7 +308,9 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values( assert virtual["value_source"] == { "entity_id": "entity-rich-1", "kind": "entity_state", } - assert virtual["value_badge"]["source"] == {"kind": "derived_lqi"} + assert virtual["value_badge"] == { + "enabled": True, "position": "bottom", "source": {"kind": "derived_lqi"}, + } assert entity["value_source"] == { "kind": "derived_marker_state", "ref": "marker:marker-rich-1", } @@ -331,6 +340,44 @@ def test_projection_helpers_fail_closed_on_malformed_shapes(): assert support_package._binding_kind(None) == "unknown" +@pytest.mark.parametrize( + ("enabled", "position", "expected"), + [ + ({"private": "sentinel"}, "right", {"position": "right"}), + (True, {"private": "sentinel"}, {"enabled": True}), + ([], "sideways", None), + ("true", ["bottom"], None), + ], +) +def test_value_badge_projection_drops_malformed_scalar_fields( + enabled, position, expected, +): + projected = support_package._project_marker( + support_package._Pseudonyms("badge"), + { + "id": "marker", + "binding": "virtual", + "value_badge": {"enabled": enabled, "position": position}, + }, + ) + assert projected.get("value_badge") == expected + assert "sentinel" not in json.dumps(projected) + + +def test_value_badge_projection_omits_empty_result(): + projected = support_package._project_marker( + support_package._Pseudonyms("badge"), + { + "id": "marker", + "binding": "virtual", + "value_badge": { + "enabled": [], "position": {}, "source": {"kind": "private"}, + }, + }, + ) + assert "value_badge" not in projected + + def test_package_size_limit_is_enforced_after_projection(monkeypatch): monkeypatch.setattr(support_package, "MAX_SUPPORT_ATTACHMENT_BYTES", 1) with pytest.raises(SupportPackageError, match="support_package_too_large"):