mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2
R3-1 (high): v1.45.0 made the upload safe but left deletion to the client — after a successful save the card asked the backend to remove everything but the file it had just committed. Two open editors cannot be ordered: a delayed request from one deleted the plan the other had just saved, leaving the accepted configuration pointing at nothing, the exact damage copy-on-write was introduced to prevent. houseplan/plan/cleanup is removed. config/set collects inside its own write lock from the two configurations that bracket the commit (plans.collect_plans): a file the old revision referenced and the new one does not is superseded and goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a fresh one may belong to a transaction that has not committed yet. The collector lives in a pure module so it can be reasoned about and unit-tested without the HA harness. R3-2: houseplan-space-card signed its plan url and threw the result away — getCardSize() mutated a throwaway model while render() rebuilt its own from the config, so the <image> requested the protected path and got 401 on every render. Both cards now share ContentSigner (src/signing.ts), which also gives the static card batching, expiry handling and periodic re-signing. is released in finally: one failed request no longer wedges a url for the life of the page. Tests: five backend interleaving cases from the report, six unit tests for the pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build: the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22 backend-pure. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -15,6 +15,44 @@ v = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(v)
|
||||
|
||||
|
||||
def _load_pure(name):
|
||||
"""Load one pure module of the integration without importing the package.
|
||||
|
||||
custom_components/houseplan/__init__.py pulls in Home Assistant, which the
|
||||
local sandbox does not have; but plans.py is deliberately pure, so a tiny
|
||||
stub package lets it keep its normal relative imports.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
pkg_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "custom_components", "houseplan"
|
||||
)
|
||||
pkg = sys.modules.get("hp_pure")
|
||||
if pkg is None:
|
||||
pkg = types.ModuleType("hp_pure")
|
||||
pkg.__path__ = [pkg_dir]
|
||||
sys.modules["hp_pure"] = pkg
|
||||
for dep in ("const", "validation"):
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{dep}", os.path.join(pkg_dir, f"{dep}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{dep}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{name}", os.path.join(pkg_dir, f"{name}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{name}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
plans = _load_pure("plans")
|
||||
const = importlib.import_module("hp_pure.const")
|
||||
|
||||
|
||||
def test_sanitize_marker_id():
|
||||
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
|
||||
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
|
||||
@@ -173,3 +211,84 @@ def test_openings_cap_enforced():
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
|
||||
# ---------- plan-file collection (review R3-1) ----------
|
||||
|
||||
|
||||
def _plans(tmp_path, names, age=0.0):
|
||||
import os, time
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir(exist_ok=True)
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
if age:
|
||||
t = time.time() - age
|
||||
os.utime(d / n, (t, t))
|
||||
return d
|
||||
|
||||
|
||||
def _cfg(*urls):
|
||||
return {"spaces": [{"id": f"s{i}", "plan_url": u} for i, u in enumerate(urls)]}
|
||||
|
||||
|
||||
def test_plan_basename_and_refs():
|
||||
plan_basename, plan_refs, is_plan_file = plans.plan_basename, plans.plan_refs, plans.is_plan_file
|
||||
|
||||
assert plan_basename("/api/houseplan/content/plans/_/f1.abc.png?v=7") == "f1.abc.png"
|
||||
assert plan_basename("/houseplan_files/plans/f1.svg") == "f1.svg"
|
||||
assert plan_basename(None) == "" and plan_basename("") == "" and plan_basename(7) == ""
|
||||
assert plan_refs(None) == set() and plan_refs({}) == set()
|
||||
assert plan_refs(_cfg("/p/a.png", None, "/p/b.svg")) == {"a.png", "b.svg"}
|
||||
assert is_plan_file("f1.svg") and is_plan_file("f1.tok.png")
|
||||
assert not is_plan_file("notes.txt") and not is_plan_file("readme")
|
||||
assert not is_plan_file("deep.name.with.dots.png") # 4 parts: not ours
|
||||
|
||||
|
||||
def test_collect_plans_removes_only_the_superseded_file(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.old.png", "f1.new.png", "f2.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.old.png", "/p/f2.png"), _cfg("/p/f1.new.png", "/p/f2.png"))
|
||||
assert removed == 1
|
||||
assert not (d / "f1.old.png").exists()
|
||||
assert (d / "f1.new.png").is_file() and (d / "f2.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
"""Another client may be mid-transaction: its file is unreferenced but young."""
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.committed.png", "f1.inflight.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.committed.png"), _cfg("/p/f1.committed.png"))
|
||||
assert removed == 0
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
|
||||
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
|
||||
assert removed == 1
|
||||
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
old = PLAN_ORPHAN_TTL_S + 60
|
||||
d = _plans(tmp_path, ["f1.png", "notes.txt", "readme", "deep.name.with.dots.png"], age=old)
|
||||
removed = collect_plans(d, _cfg("/p/f1.png"), _cfg("/p/f1.png"))
|
||||
assert removed == 0
|
||||
for n in ("f1.png", "notes.txt", "readme", "deep.name.with.dots.png"):
|
||||
assert (d / n).is_file()
|
||||
|
||||
|
||||
def test_collect_plans_survives_a_missing_directory(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
|
||||
|
||||
Reference in New Issue
Block a user