mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Attachment uploads stage the body as `.upload-*` under files_root and then asked the quota to count that file as stored usage *and* as the incoming size, so the last file that still fit was refused at the boundary — by bytes and by count. check_quota/dir_usage now take `exclude` for the caller's own staged file; other staged files keep counting, so two concurrent uploads can never both land past the limit. The support package copied every string key of settings.fill_colors. The schema stays open for compatibility, but the projection now keeps only the eleven slots the card defines (SUPPORT_FILL_COLOR_KEYS, pinned to src/logic.ts DEFAULT_FILL_COLORS by a test); an empty palette is omitted. The SVG local-reference walk was a recursive DFS: a flat chain of a few thousand hrefs passed every #436 bound and died with RecursionError, which the upload view turned into a 500. The walk is iterative and measures the longest chain through each node (memoised, order-independent); chains deeper than MAX_SVG_REF_DEPTH = 64 are refused as too_large, cycles stay invalid_image. Tests: quota boundaries on the validator and the HA endpoint, a barrier test for concurrent uploads, palette allowlist and TS parity, reference chains (plain, hostile id order, cycle) on the validator and the endpoint; six mutants caught by the standard runner. Issue: #498 User-Visible: yes
437 lines
18 KiB
Python
437 lines
18 KiB
Python
"""Privacy and referential-integrity contract of the #43 support package."""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from hashlib import sha256
|
|
|
|
import pytest
|
|
|
|
from custom_components.houseplan import support_package
|
|
from custom_components.houseplan.support_package import (
|
|
SupportPackageError,
|
|
build_support_package,
|
|
validate_frontend_facts,
|
|
)
|
|
|
|
|
|
def _facts() -> dict:
|
|
return {
|
|
"browser_family": "chromium",
|
|
"browser_major": 140,
|
|
"language": "ru",
|
|
"coarse_pointer": False,
|
|
"hover_capable": True,
|
|
"registry_access": "full",
|
|
"registry_age_bucket": "fresh",
|
|
}
|
|
|
|
|
|
def _source() -> tuple[dict, dict, list[str]]:
|
|
forbidden = [
|
|
"sentinel-space-id", "Private upstairs", "sentinel-room-id", "Child room",
|
|
"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", "Private summary title",
|
|
"Private summary block", "Private summary row", "sensor.private_summary",
|
|
]
|
|
config = {
|
|
"model_version": 9,
|
|
"unknown_top": "unknown-private-value",
|
|
"settings": {
|
|
"bg_mode": "daynight",
|
|
"known_devices": ["device-secret"],
|
|
"unknown_nested": "unknown-private-value",
|
|
"summary_panel": {
|
|
"version": 1,
|
|
"title": "Private summary title",
|
|
"show_on_mobile": True,
|
|
"blocks": [{
|
|
"id": "summary-private", "title": "Private summary block",
|
|
"visible": True, "scope": {"type": "all"},
|
|
"values": [{
|
|
"id": "summary-value-private", "label": "Private summary row",
|
|
"source": {"type": "entity", "entity_id": "sensor.private_summary"},
|
|
}],
|
|
}],
|
|
},
|
|
},
|
|
"spaces": [{
|
|
"id": "sentinel-space-id",
|
|
"title": "Private upstairs",
|
|
"plan_url": "https://private.example/plan.png",
|
|
"view_box": [0, 0, 1, 1],
|
|
"rooms": [{
|
|
"id": "sentinel-room-id",
|
|
"name": "Child room",
|
|
"area": "area.private",
|
|
"poly": [[0, 0], [1, 0], [1, 1], [0, 1]],
|
|
"wall_ids": ["w1", "w2", "w3", "w4"],
|
|
"settings": {"temp_source": "entity:sensor.secret_temperature"},
|
|
}],
|
|
"wall_segments": [
|
|
{"id": "w1", "a": [0, 0], "b": [1, 0], "cm": 10},
|
|
{"id": "w2", "a": [1, 0], "b": [1, 1], "cm": 10},
|
|
{"id": "w3", "a": [1, 1], "b": [0, 1], "cm": 10},
|
|
{"id": "w4", "a": [0, 1], "b": [0, 0], "cm": 10},
|
|
],
|
|
"openings": [{
|
|
"id": "opening-secret", "type": "door", "x": 0.5, "y": 0,
|
|
"angle": 0, "length": 0.2,
|
|
"contact": "binary_sensor.private_door",
|
|
"host": {"kind": "wall", "id": "w1", "t": 0.5},
|
|
}],
|
|
"decor": [{
|
|
"id": "decor-secret", "kind": "text", "x": 0.4, "y": 0.4,
|
|
"text": "<b>private note</b>", "entity": "sensor.secret_temperature",
|
|
"attr": "person@example.test", "unknown": "unknown-private-value",
|
|
}],
|
|
}],
|
|
"markers": [{
|
|
"id": "marker-secret", "binding": "device:device-secret",
|
|
"space": "sentinel-space-id", "room_id": "sentinel-room-id",
|
|
"area": "area.private", "name": "Private washer", "model": "Secret model",
|
|
"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",
|
|
}],
|
|
}
|
|
layout = {
|
|
"marker-secret": {"s": "sentinel-space-id", "x": 0.25, "y": 0.75},
|
|
"rl_sentinel-room-id": {"s": "sentinel-space-id", "x": 0.5, "y": 0.5, "k": 1.2},
|
|
"device-secret": {"s": "sentinel-space-id", "x": 0.1, "y": 0.2},
|
|
}
|
|
return config, layout, forbidden
|
|
|
|
|
|
def _build(namespace: str = "test"):
|
|
config, layout, forbidden = _source()
|
|
raw, summary = build_support_package(
|
|
config,
|
|
layout,
|
|
config_rev=17,
|
|
layout_rev=24,
|
|
card_version="1.70.0-beta.2",
|
|
integration_version="1.70.0-beta.2",
|
|
home_assistant_version="2026.8.0",
|
|
runtime=_facts(),
|
|
repairs=[{"code": "broken_plan", "count": 1}],
|
|
namespace=namespace,
|
|
)
|
|
return raw, summary, forbidden
|
|
|
|
|
|
def test_package_is_canonical_and_preview_hash_describes_exact_bytes():
|
|
first, summary, _ = _build()
|
|
second, _, _ = _build()
|
|
assert first == second
|
|
assert first.endswith(b"\n")
|
|
assert summary["size"] == len(first)
|
|
assert summary["sha256"] == sha256(first).hexdigest()
|
|
assert json.dumps(json.loads(first), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" == first.decode()
|
|
|
|
|
|
def test_privacy_projection_never_contains_raw_or_encoded_forbidden_values():
|
|
raw, _, forbidden = _build()
|
|
text = raw.decode()
|
|
for value in forbidden:
|
|
assert value not in text
|
|
assert json.dumps(value, ensure_ascii=False)[1:-1] not in text
|
|
assert base64.b64encode(value.encode()).decode() not in text
|
|
|
|
|
|
def test_geometry_and_references_survive_with_package_local_pseudonyms():
|
|
raw, _, _ = _build()
|
|
package = json.loads(raw)
|
|
space = package["plan_backup"]["config"]["spaces"][0]
|
|
room = space["rooms"][0]
|
|
marker = package["plan_backup"]["config"]["markers"][0]
|
|
assert space["id"] == "space-test-1"
|
|
assert space["title"] == "Space 1"
|
|
assert room["id"] == "room-test-1"
|
|
assert room["name"] == "Room 1"
|
|
assert room["poly"] == [[0, 0], [1, 0], [1, 1], [0, 1]]
|
|
assert room["wall_ids"] == ["wall-test-1", "wall-test-2", "wall-test-3", "wall-test-4"]
|
|
assert space["openings"][0]["host"]["id"] == room["wall_ids"][0]
|
|
assert marker["space"] == space["id"]
|
|
assert marker["room_id"] == room["id"]
|
|
assert marker["binding"] == "device:device-test-1"
|
|
assert set(package["plan_backup"]["layout"]) == {"marker-test-1", "rl_room-test-1"}
|
|
assert space["decor"][0]["text"] == "[redacted text]"
|
|
assert package["summary"]["decor"] == {"text": 1}
|
|
assert package["summary"]["markers"] == {
|
|
"total": 1,
|
|
"lifecycle": {"active": 1},
|
|
"binding": {"device": 1},
|
|
}
|
|
assert "summary_panel" not in package["plan_backup"]["config"]["settings"]
|
|
|
|
|
|
def test_each_preview_uses_a_new_namespace_and_cannot_be_correlated():
|
|
first, _, _ = _build("one1")
|
|
second, _, _ = _build("two2")
|
|
assert first != second
|
|
assert "space-one1-1" in first.decode()
|
|
assert "space-two2-1" in second.decode()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
("browser_family", "netscape"),
|
|
("browser_major", -1),
|
|
("language", "es"),
|
|
("coarse_pointer", "false"),
|
|
("registry_access", "raw"),
|
|
("registry_age_bucket", "yesterday"),
|
|
],
|
|
)
|
|
def test_frontend_facts_fail_closed(key, value):
|
|
facts = _facts()
|
|
facts[key] = value
|
|
with pytest.raises(SupportPackageError, match="support_rejected"):
|
|
validate_frontend_facts(facts)
|
|
|
|
|
|
def test_frontend_facts_reject_non_mapping_and_invalid_hover():
|
|
with pytest.raises(SupportPackageError, match="support_rejected"):
|
|
validate_frontend_facts(None)
|
|
facts = _facts()
|
|
facts["hover_capable"] = 1
|
|
with pytest.raises(SupportPackageError, match="support_rejected"):
|
|
validate_frontend_facts(facts)
|
|
|
|
|
|
def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values():
|
|
config = {
|
|
"model_version": 9,
|
|
"settings": {
|
|
"north_deg": 30,
|
|
"show_room_tooltip": False,
|
|
"zigbee_topology": {
|
|
"enabled": True,
|
|
"z2m_base_topics": ["private/site/zigbee2mqtt"],
|
|
},
|
|
"fill_colors": {
|
|
"temp_hot": {"c": "#ffaa00", "a": 0.5, "secret": "drop"},
|
|
"private-owner@example.test": {"c": "#010101", "a": 0.1},
|
|
},
|
|
"decor_default_style": {
|
|
"color": "#123456", "width_cm": 2, "secret": "drop",
|
|
},
|
|
},
|
|
"spaces": [{
|
|
"id": "floor",
|
|
"settings": {"show_names": True, "custom_fill": {"c": "#ffffff", "a": 0.4}},
|
|
"rooms": [{
|
|
"id": "kitchen", "x": 1, "y": 2, "w": 3, "h": 4,
|
|
"poly": [[0, 0], [2, 0], [False, 1], "bad"],
|
|
"wall_ids": ["wall-a"], "open_to": ["hall"],
|
|
"settings": {
|
|
"fill_mode": "custom", "custom_fill": {"c": "#000000", "a": 0.2},
|
|
"temp_source": "entity:sensor.temperature",
|
|
"hum_source": "invalid-source",
|
|
},
|
|
}],
|
|
"walls": [
|
|
{"key": "wall-a", "cm": 10, "a": [0, 0], "b": [2, 0]},
|
|
{"key": "wall-b", "cm": 12},
|
|
"bad",
|
|
],
|
|
"room_drafts": [
|
|
"bad",
|
|
{
|
|
"id": "draft-a", "points": [[0, 0], [1, 0], [True, 2]],
|
|
"segments": [{"id": "wall-a", "cm": 10}, {"cm": 12}, "bad"],
|
|
},
|
|
],
|
|
"partitions": [{"id": "partition-a", "a": [0, 1], "b": [2, 1], "cm": 8}],
|
|
"wall_columns": [{
|
|
"id": "column-a", "shape": "rect", "center": [1, 1], "cm": 20, "angle": 0,
|
|
}],
|
|
"openings": [
|
|
"bad",
|
|
{
|
|
"id": "door-a", "type": "door", "x": 1, "y": 1, "length": 0.9,
|
|
"contact": "binary_sensor.door", "lock": "lock.door",
|
|
"host": {"kind": "partition", "id": "partition-a", "t": 0.5},
|
|
},
|
|
{"id": "window-a", "type": "window", "host": {"kind": "column", "id": "x"}},
|
|
],
|
|
"decor": [
|
|
{"id": "line-a", "kind": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
|
|
{"id": "text-a", "kind": "text", "text": "private"},
|
|
],
|
|
"open_spans": [{"a": [0, 0], "b": [0, 1]}, "bad"],
|
|
}],
|
|
"markers": [
|
|
{
|
|
"id": "virtual-a", "binding": "virtual", "icon": "mdi:lightbulb",
|
|
"space": "floor", "room_id": "kitchen", "hidden": True,
|
|
"light_entity": "light.ceiling", "toggle_entity": "switch.ceiling",
|
|
"tap_target": "button.scene", "controls": ["sensor.temperature"],
|
|
"vacuum": {"live": True, "trail": True, "trail_mode": "line", "secret": "drop"},
|
|
"value_source": {"kind": "entity_state", "entity_id": "sensor.temperature"},
|
|
"value_badge": {
|
|
"enabled": True, "position": "bottom",
|
|
"source": {"kind": "derived_lqi"},
|
|
},
|
|
},
|
|
{
|
|
"id": "entity-a", "binding": "entity:sensor.temperature", "removed": True,
|
|
"value_source": {"kind": "derived_marker_state", "ref": "marker:virtual-a"},
|
|
},
|
|
{
|
|
"id": "unknown-a", "binding": "secret", "icon": "custom:private",
|
|
"value_source": {"kind": "private", "entity_id": "sensor.private"},
|
|
},
|
|
],
|
|
}
|
|
layout = {
|
|
"virtual-a": {"s": "floor", "x": 0.25, "y": 0.75},
|
|
"rl_kitchen": {"s": "floor", "x": 0.5, "y": 0.5, "k": 1.1},
|
|
7: {"x": 0},
|
|
"unknown-device": "bad",
|
|
}
|
|
|
|
raw, _ = build_support_package(
|
|
config, layout, config_rev=1, layout_rev=2,
|
|
card_version="invalid version!", integration_version="1.0.0",
|
|
home_assistant_version="2026.8.0", runtime=_facts(), namespace="rich",
|
|
)
|
|
package = json.loads(raw)
|
|
plan = package["plan_backup"]["config"]
|
|
space = plan["spaces"][0]
|
|
|
|
assert package["versions"]["card"] == "unknown"
|
|
assert plan["settings"]["fill_colors"] == {"temp_hot": {"a": 0.5, "c": "#ffaa00"}}
|
|
assert b"private-owner" not in raw and b"example.test" not in raw
|
|
assert plan["settings"]["show_room_tooltip"] is False
|
|
assert "zigbee_topology" not in plan["settings"]
|
|
assert b"private/site/zigbee2mqtt" not in raw
|
|
assert plan["settings"]["decor_default_style"] == {"color": "#123456", "width_cm": 2}
|
|
assert space["rooms"][0]["poly"] == [[0, 0], [2, 0]]
|
|
assert space["rooms"][0]["settings"]["temp_source_kind"] == "entity"
|
|
assert space["rooms"][0]["settings"]["hum_source_kind"] == "unknown"
|
|
assert len(space["walls"]) == 2
|
|
assert space["walls"][1] == {"cm": 12, "key": "wall-rich-2"}
|
|
assert "room_drafts" not in space
|
|
assert space["partitions"][0]["cm"] == 8
|
|
assert space["openings"][0]["host"]["kind"] == "partition"
|
|
assert "host" not in space["openings"][1]
|
|
assert space["decor"][1]["text"] == "[redacted text]"
|
|
|
|
virtual, entity, unknown = plan["markers"]
|
|
assert virtual["binding_kind"] == "virtual"
|
|
assert virtual["icon"] == "mdi:lightbulb"
|
|
assert virtual["vacuum"] == {"live": True, "trail": True, "trail_mode": "line"}
|
|
assert virtual["value_source"] == {
|
|
"entity_id": "entity-rich-1", "kind": "entity_state",
|
|
}
|
|
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",
|
|
}
|
|
assert unknown["binding_kind"] == "unknown"
|
|
assert "icon" not in unknown and "value_source" not in unknown
|
|
assert set(package["plan_backup"]["layout"]) == {"marker-rich-1", "rl_room-rich-1"}
|
|
assert package["summary"]["markers"] == {
|
|
"total": 3,
|
|
"lifecycle": {"active": 1, "hidden": 1, "removed": 1},
|
|
"binding": {"entity": 1, "unknown": 1, "virtual": 1},
|
|
}
|
|
|
|
|
|
def test_projection_helpers_fail_closed_on_malformed_shapes():
|
|
ids = support_package._Pseudonyms("edge")
|
|
assert ids.get("room", None) is None
|
|
assert ids.get("room", "") is None
|
|
assert ids.get("room", "same") == ids.get("room", "same")
|
|
assert support_package._point(None) is None
|
|
assert support_package._point([True, 1]) is None
|
|
assert support_package._points(None) == []
|
|
assert support_package._custom_fill(None) is None
|
|
assert support_package._global_settings(None) == {}
|
|
assert support_package._global_settings({"show_room_tooltip": "false"}) == {}
|
|
assert support_package._room_settings(ids, None) == {}
|
|
assert support_package._project_layout(ids, None) == {}
|
|
assert support_package._summary(None, None)["spaces"] == 0
|
|
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"):
|
|
_build()
|
|
|
|
|
|
def test_issue_498_palette_allowlist_matches_the_card_defaults():
|
|
"""The package keeps exactly the palette slots the card reads (src/logic.ts)."""
|
|
import os
|
|
import re
|
|
|
|
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
|
|
with open(src, encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
block = re.search(r"export const DEFAULT_FILL_COLORS: FillColors = \{(.*?)\};", text, re.S)
|
|
assert block, "DEFAULT_FILL_COLORS not found in src/logic.ts"
|
|
card_keys = re.findall(r"^\s*([a-z_]+):\s*\{", block.group(1), re.M)
|
|
assert set(card_keys) == set(support_package.SUPPORT_FILL_COLOR_KEYS)
|
|
assert len(card_keys) == len(support_package.SUPPORT_FILL_COLOR_KEYS) == 11
|
|
|
|
|
|
def test_issue_498_projection_omits_an_empty_palette():
|
|
assert "fill_colors" not in support_package._global_settings({"fill_colors": {}})
|
|
assert "fill_colors" not in support_package._global_settings({"fill_colors": {"warm": {"c": "#fff", "a": 1}}})
|
|
assert support_package._global_settings({"fill_colors": {"wall_fill": {"c": "#fff", "a": 1, "x": 1}}}) == {
|
|
"fill_colors": {"wall_fill": {"c": "#fff", "a": 1}},
|
|
}
|