mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.
R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.
R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.
Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.
Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -99,7 +99,103 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["url"].startswith("/api/houseplan/content/plans/_/s1.png?v=")
|
||||
url = resp["result"]["url"]
|
||||
assert resp["success"]
|
||||
# versioned name: "<space>.<token>.<ext>" (review R2-1)
|
||||
name = url.rsplit("/", 1)[-1]
|
||||
assert name.startswith("s1.") and name.endswith(".png") and len(name.split(".")) == 3
|
||||
|
||||
|
||||
async def test_plan_upload_does_not_touch_the_previous_file(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""review R2-1: a rejected config write must leave the stored plan intact.
|
||||
|
||||
The upload used to overwrite "<space>.<ext>" (and unlink the other
|
||||
extension) BEFORE the revision-checked config write, so a conflict left the
|
||||
live plan replaced — or, with a new extension, pointing at a deleted file.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
legacy = plans / "s1.svg" # what an older version stored, still referenced
|
||||
legacy.write_bytes(b"<svg>old</svg>")
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||
)
|
||||
first = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
|
||||
|
||||
# pretend the config write was rejected: no cleanup call follows
|
||||
assert legacy.read_bytes() == b"<svg>old</svg>"
|
||||
assert (plans / first).is_file()
|
||||
|
||||
# a second attempt neither overwrites the first nor the legacy file
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "d29ybGQ="}
|
||||
)
|
||||
second = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
|
||||
assert second != first
|
||||
assert (plans / first).read_bytes() == b"hello"
|
||||
assert (plans / second).read_bytes() == b"world"
|
||||
assert legacy.is_file()
|
||||
|
||||
# config accepted → cleanup keeps exactly the referenced file
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/cleanup", "space_id": "s1", "keep": second}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["removed"] == 2
|
||||
assert (plans / second).is_file()
|
||||
assert not legacy.exists() and not (plans / first).exists()
|
||||
|
||||
|
||||
async def test_plan_cleanup_never_reaches_another_space(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A space id cannot contain '.', so "<space>.<token>.<ext>" is unambiguous.
|
||||
|
||||
With '-' as the separator, cleaning "f1" would have eaten the files of a
|
||||
space called "f1-attic".
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("f1.aaaa1111.png", "f1.bbbb2222.png", "f1-attic.cccc3333.png", "f1-attic.png", "notes.txt"):
|
||||
(plans / name).write_bytes(b"x")
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/cleanup", "space_id": "f1", "keep": "f1.bbbb2222.png"}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["removed"] == 1
|
||||
assert not (plans / "f1.aaaa1111.png").exists()
|
||||
assert (plans / "f1.bbbb2222.png").is_file()
|
||||
assert (plans / "f1-attic.cccc3333.png").is_file()
|
||||
assert (plans / "f1-attic.png").is_file()
|
||||
assert (plans / "notes.txt").is_file()
|
||||
|
||||
|
||||
async def test_plan_cleanup_rejects_a_bad_space_id(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/cleanup", "space_id": "../evil", "keep": "x.png"}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "invalid_space_id"
|
||||
|
||||
|
||||
async def test_admin_check_fails_closed(hass, hass_ws_client):
|
||||
|
||||
Reference in New Issue
Block a user