fix(plans): загрузка плана по HTTP, предел 8 МБ проверяется до отправки (#617)

План уходил base64 в WebSocket-кадре: файл больше ~3 МиБ давал кадр больше
4 МиБ, HA закрывал сокет до обработчика, и обещанные 8 МБ были недостижимы.

- бэкенд: HouseplanPlanUploadView (POST /api/houseplan/plans/upload), потоковый
  предел MAX_PLAN_BYTES (read_bounded), общий writer store_plan_upload для view
  и ws_plan_set (контракт WS без изменений);
- карточка: stagePlanFile/uploadPlanFile/renderPlanBackdropGuard в
  backdrop-pick.ts для обоих рантаймов; PlanFilePayload хранит Blob вместо b64;
  SVG больше предела — тост при выборе, растр — диалог #39 только с уменьшенной
  копией, копия больше предела не попадает в staging, 413 называет предел;
- i18n toast.plan_too_large, backdrop.over_limit_body (en/ru/de/fr);
  USER-GUIDE ru/en, CHANGELOG ru/en, docs/testing-notes (#617);
- тесты: test/plan-upload-limit.test.mjs, tests_backend/test_plan_upload.py,
  test_ha_upload.py (#617), smoke_plan_upload_limit.mjs; три смока переведены
  с b64/plan/set на blob/fetchWithAuth; мутанты plan-upload-*;
- база монолита: hostRefs +3 (общий хелпер плана вместо двух копий в рантаймах,
  новый тост уменьшенной копии).

Issue: #617
User-Visible: yes
This commit is contained in:
Claude
2026-09-24 19:57:41 +03:00
parent f8e089bdf2
commit 857369b18b
56 changed files with 2077 additions and 232 deletions
+138
View File
@@ -214,3 +214,141 @@ async def test_issue_498_decor_upload_refuses_a_deep_reference_chain_with_a_code
ok.add_field("file", _svg_chain(64), filename="chain64.svg", content_type="image/svg+xml")
accepted = await client.post("/api/houseplan/assets/upload", data=ok)
assert accepted.status == 200, await accepted.text()
# ---------------- #617: plan upload over HTTP ----------------
def _plan_form(data: bytes, space_id: str = "f1", ext: str = "png", files: int = 1) -> FormData:
fd = FormData()
fd.add_field("space_id", space_id)
fd.add_field("ext", ext)
for _ in range(files):
fd.add_field("file", data, filename=f"plan.{ext}", content_type="application/octet-stream")
return fd
def _plans_listing(hass: HomeAssistant) -> list[str]:
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
root = Path(hass.config.path(PLANS_DIR))
return sorted(p.name for p in root.iterdir()) if root.is_dir() else []
async def test_issue_617_plan_upload_stores_a_5_mib_plan_byte_for_byte(
hass: HomeAssistant, hass_client: ClientSessionGenerator,
) -> None:
"""#617 AC1: a 5 MiB plan — above the old ~3 MiB WebSocket ceiling — is stored."""
import hashlib
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_client()
raw = b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * (5 * 1024 * 4 - 1) + b"x" * 248
assert len(raw) == 5 * 1024 * 1024
resp = await client.post("/api/houseplan/plans/upload", data=_plan_form(raw))
assert resp.status == 200, await resp.text()
body = await resp.json()
assert body["ok"] is True
assert body["url"].startswith("/api/houseplan/content/plans/_/f1.")
assert body["url"].endswith(".png")
name = body["url"].rsplit("/", 1)[-1]
stored = Path(hass.config.path(PLANS_DIR)) / name
digest = await hass.async_add_executor_job(lambda: hashlib.sha256(stored.read_bytes()).hexdigest())
assert digest == hashlib.sha256(raw).hexdigest()
async def test_issue_617_plan_upload_limit_is_inclusive_and_refusal_leaves_nothing(
hass: HomeAssistant, hass_client: ClientSessionGenerator,
) -> None:
"""#617 AC4: exactly MAX_PLAN_BYTES passes; one byte more is 413 with max_mb, no file."""
from custom_components.houseplan.validation import MAX_PLAN_BYTES
await _setup(hass)
client = await hass_client()
exact = await client.post(
"/api/houseplan/plans/upload", data=_plan_form(b"\0" * MAX_PLAN_BYTES, "fexact"),
)
assert exact.status == 200, await exact.text()
before = await hass.async_add_executor_job(_plans_listing, hass)
over = await client.post(
"/api/houseplan/plans/upload", data=_plan_form(b"\0" * (MAX_PLAN_BYTES + 1), "fover"),
)
assert over.status == 413
assert await over.json() == {"error": "too_large", "max_mb": 8}
after = await hass.async_add_executor_job(_plans_listing, hass)
assert after == before, "a refused plan leaves neither a file nor a temporary behind"
async def test_issue_617_plan_upload_refuses_non_admin(
hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_read_only_access_token: str,
) -> None:
"""#617 AC4: the same write policy as ws_plan_set."""
await _setup(hass)
client = await hass_client(hass_read_only_access_token)
before = await hass.async_add_executor_job(_plans_listing, hass)
resp = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"PLAN"))
assert resp.status == 403
assert (await resp.json())["error"] == "unauthorized"
assert await hass.async_add_executor_job(_plans_listing, hass) == before
async def test_issue_617_plan_upload_validates_fields_like_ws_plan_set(
hass: HomeAssistant, hass_client: ClientSessionGenerator,
) -> None:
"""#617 AC4: space id and extension are checked; one file per request."""
await _setup(hass)
client = await hass_client()
before = await hass.async_add_executor_job(_plans_listing, hass)
bad_space = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"x", "../evil"))
assert bad_space.status == 400
assert (await bad_space.json())["error"] == "invalid_space_id"
bad_ext = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"x", "f1", "gif"))
assert bad_ext.status == 400
assert (await bad_ext.json())["error"] == "bad_ext"
no_file = FormData()
no_file.add_field("space_id", "f1")
no_file.add_field("ext", "png")
missing = await client.post("/api/houseplan/plans/upload", data=no_file)
assert missing.status == 400
assert (await missing.json())["error"] == "no_file"
two = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"x", files=2))
assert two.status == 400
assert (await two.json())["error"] == "one_file_only"
assert await hass.async_add_executor_job(_plans_listing, hass) == before
async def test_issue_617_plan_upload_quota_answers_507_with_reason(
hass: HomeAssistant, hass_client: ClientSessionGenerator, monkeypatch,
) -> None:
"""#617 AC4: the plan store quota is the one ws_plan_set enforces."""
from pathlib import Path
from custom_components.houseplan import http_api as hp_http
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
await _setup(hass)
client = await hass_client()
_bytes, stored = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR)),
)
monkeypatch.setattr(hp_http, "MAX_PLANS_FILES", stored) # no room for one more
resp = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"PLAN"))
assert resp.status == 507
body = await resp.json()
assert body["error"] == "too_many_files" and body["detail"]
_bytes2, after = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR)),
)
assert after == stored
+120
View File
@@ -0,0 +1,120 @@
"""#617: plan upload bound and the single writer shared by HTTP and WebSocket.
Pure — no Home Assistant needed. The HTTP view itself (status codes, auth,
multipart) is covered by `test_ha_upload.py` in the HA harness (Linux CI).
"""
from __future__ import annotations
import asyncio
import hashlib
import re
from pathlib import Path
import pytest
from pure_imports import HOUSEPLAN_ROOT, load_pure
plans = load_pure("custom_components.houseplan.plans", HOUSEPLAN_ROOT / "plans.py")
validation = load_pure("custom_components.houseplan.validation", HOUSEPLAN_ROOT / "validation.py")
CHUNK = 64 * 1024
class _Part:
"""Minimal stand-in for an aiohttp BodyPartReader: only `read_chunk`."""
def __init__(self, data: bytes) -> None:
self._data = data
self._pos = 0
self.reads = 0
async def read_chunk(self, size: int = CHUNK) -> bytes:
self.reads += 1
block = self._data[self._pos:self._pos + size]
self._pos += len(block)
return block
def _read(data: bytes, limit: int) -> tuple[bytes | None, _Part]:
part = _Part(data)
return asyncio.run(plans.read_bounded(part, limit, CHUNK)), part
def test_issue_617_read_bounded_accepts_exactly_the_limit():
limit = 3 * CHUNK + 17
data = bytes(range(256)) * (limit // 256) + b"x" * (limit % 256)
assert len(data) == limit
out, _part = _read(data, limit)
assert out == data
def test_issue_617_read_bounded_refuses_one_byte_over_and_stops_reading():
limit = 3 * CHUNK + 17
data = b"y" * (limit + 1) + b"z" * (5 * CHUNK)
out, part = _read(data, limit)
assert out is None, "one byte over the plan limit must be refused"
# the read stops at the first block that crosses the bound: an oversized
# body never gets buffered whole
assert part.reads == 4
def test_issue_617_read_bounded_real_plan_limit_boundary():
limit = validation.MAX_PLAN_BYTES
assert limit == 8 * 1024 * 1024
exact, _ = _read(b"\0" * limit, limit)
assert exact is not None and len(exact) == limit
over, _ = _read(b"\0" * (limit + 1), limit)
assert over is None
def _quota_ok() -> dict:
return {"max_bytes": 10 * 1024 * 1024, "max_files": 50}
def test_issue_617_store_plan_upload_writes_bytes_under_a_versioned_name(tmp_path: Path):
plans_dir = tmp_path / "plans"
raw = b"\x89PNG" + b"a" * 5000
name = plans.store_plan_upload(plans_dir, "f1", "png", raw, **_quota_ok())
assert re.fullmatch(r"f1\.[0-9a-f]{8}\.png", name)
assert plans.is_plan_file(name)
stored = plans_dir / name
assert hashlib.sha256(stored.read_bytes()).hexdigest() == hashlib.sha256(raw).hexdigest()
assert sorted(p.name for p in plans_dir.iterdir()) == [name], "no temporary file is left"
def test_issue_617_store_plan_upload_is_copy_on_write(tmp_path: Path):
plans_dir = tmp_path / "plans"
first = plans.store_plan_upload(plans_dir, "f1", "png", b"one", **_quota_ok())
second = plans.store_plan_upload(plans_dir, "f1", "png", b"two", **_quota_ok())
assert first != second
assert (plans_dir / first).read_bytes() == b"one", "the previous plan is never touched"
assert (plans_dir / second).read_bytes() == b"two"
@pytest.mark.parametrize(
("limits", "reason"),
[({"max_bytes": 10, "max_files": 50}, "quota_exceeded"),
({"max_bytes": 10 * 1024 * 1024, "max_files": 1}, "too_many_files")],
)
def test_issue_617_store_plan_upload_quota_refusal_leaves_nothing(tmp_path: Path, limits, reason):
plans_dir = tmp_path / "plans"
plans_dir.mkdir()
(plans_dir / "old.abcd1234.png").write_bytes(b"12345")
with pytest.raises(plans.QuotaError) as err:
plans.store_plan_upload(plans_dir, "f2", "png", b"123456", **limits)
assert err.value.reason == reason
assert sorted(p.name for p in plans_dir.iterdir()) == ["old.abcd1234.png"]
def test_issue_617_both_transports_write_through_the_one_writer():
"""AC7: the HTTP view and ws_plan_set share `store_plan_upload`; neither
carries its own copy of the naming/quota/write sequence."""
http_src = (HOUSEPLAN_ROOT / "http_api.py").read_text(encoding="utf-8")
ws_src = (HOUSEPLAN_ROOT / "websocket_api.py").read_text(encoding="utf-8")
view = http_src.split("class HouseplanPlanUploadView", 1)[1].split("\nclass ", 1)[0]
ws = ws_src.split("async def ws_plan_set", 1)[1].split("\ndef ", 1)[0]
for body in (view, ws):
assert body.count("store_plan_upload,") == 1
assert "atomic_write(" not in body and "check_quota(" not in body
assert "token_hex(" not in body
assert "MAX_PLAN_BYTES" in view and "read_bounded(part, MAX_PLAN_BYTES" in view