mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user