fix: type-check support badge projection

Issue: #420
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-02 19:45:46 +03:00
parent 8215b61f4d
commit c5d9775c7a
4 changed files with 76 additions and 7 deletions
+20 -6
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- При построении privacy-проекции support-пакет теперь проверяет типы флага и
позиции бейджа значения, поэтому некорректный вложенный конфиг не может
дословно попасть в отчёт
([#420](https://github.com/Matysh/houseplan-card/issues/420)).
- Провенанс автоматического переноса между HA-зонами теперь переживает временно
пустой или усечённый реестр Home Assistant: уборка сверяется с полным roster
привязок и забывает исчезнувшее устройство лишь после двух непустых
+48 -1
View File
@@ -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", "<b>private note</b>", "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"):