fix: exact upload quota, support palette allowlist, bounded SVG reference chains (#498)

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
This commit is contained in:
Codex
2026-09-09 07:06:20 +00:00
committed by claude[bot]
parent 5f8d40caf0
commit 4f040c4c8e
12 changed files with 420 additions and 31 deletions
+50 -16
View File
@@ -13,6 +13,7 @@ import re
import stat
import struct
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -34,6 +35,8 @@ MAX_RASTER_DIMENSION = 16_384
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
MAX_SVG_ELEMENTS = 5_000
MAX_SVG_DEPTH = 64
# Rendering follows href/url() chains; a chain this long is not art (#498).
MAX_SVG_REF_DEPTH = 64
MAX_SVG_ATTR_CHARS = 512_000
MAX_SVG_ATTR_VALUE_CHARS = 65_536
_SVG_TAGS = frozenset({
@@ -269,22 +272,7 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
if any(ref not in ids for ref in refs):
raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference")
visiting: set[str] = set()
visited: set[str] = set()
def _visit(node_id: str) -> None:
if node_id in visiting:
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
if node_id in visited:
return
visiting.add(node_id)
for ref in ref_graph.get(node_id, ()):
_visit(ref)
visiting.remove(node_id)
visited.add(node_id)
for node_id in ids:
_visit(node_id)
_walk_reference_graph(ids, ref_graph)
view_box = root.attrib.get("viewBox")
width = _svg_number(root.attrib.get("width"))
height = _svg_number(root.attrib.get("height"))
@@ -309,6 +297,52 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
def _walk_reference_graph(ids: set[str], ref_graph: dict[str, set[str]]) -> None:
"""Reject cycles and chains of local references longer than MAX_SVG_REF_DEPTH.
Iterative on purpose: a flat chain of a few thousand `href`s passes every
element/depth/attribute bound yet used to blow the interpreter's recursion
limit inside a recursive DFS, which the upload view answered with a 500
instead of a refusal (#498).
The limit is the longest chain *through* a node, memoised per node, not the
stack height of whichever traversal happened to reach it first: a chain
cut into short segments by a hostile `id` order must still be measured
end to end (spec review #498 r1).
"""
longest: dict[str, int] = {}
visiting: set[str] = set()
for start in sorted(ids):
if start in longest:
continue
stack: list[tuple[str, Iterator[str], int]] = [
(start, iter(sorted(ref_graph.get(start, ()))), 1),
]
visiting.add(start)
while stack:
node_id, children, chain = stack[-1]
ref = next(children, None)
if ref is None:
stack.pop()
visiting.discard(node_id)
longest[node_id] = chain
if chain > MAX_SVG_REF_DEPTH:
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
if stack:
parent, parent_children, parent_chain = stack[-1]
stack[-1] = (parent, parent_children, max(parent_chain, chain + 1))
continue
if ref in visiting:
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
if ref in longest:
stack[-1] = (node_id, children, max(chain, longest[ref] + 1))
continue
if len(stack) > MAX_SVG_REF_DEPTH:
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
visiting.add(ref)
stack.append((ref, iter(sorted(ref_graph.get(ref, ()))), 1))
def validate_asset(
data: bytes, filename: str, declared_mime: str | None = None,
) -> ValidatedAsset:
+6 -2
View File
@@ -445,9 +445,13 @@ class HouseplanUploadView(HomeAssistantView):
tmp_path = temps[0]
try:
# The staged file already sits under files_root: hand it to
# the quota as `incoming` only, not as stored usage too (#498).
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
partial(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
),
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
+15 -5
View File
@@ -198,12 +198,20 @@ class QuotaError(Exception):
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read.
`exclude` is the caller's own staged upload: it already lives under `path`
and its size arrives separately as `incoming`, so counting it here would
charge the same bytes and the same file twice (#498). Any *other* staged
file stays in the count — it is about to become an attachment.
"""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
if exclude is not None and item == exclude:
continue
try:
if item.is_file():
total += item.stat().st_size
@@ -213,8 +221,10 @@ def dir_usage(path: Path) -> tuple[int, int]:
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
def check_quota(
path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None,
) -> None:
"""Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage).
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
@@ -222,7 +232,7 @@ def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> No
"""
import shutil
used, count = dir_usage(path)
used, count = dir_usage(path, exclude=exclude)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
+15 -4
View File
@@ -136,6 +136,15 @@ def _custom_fill(value: object) -> dict[str, Any] | None:
return _copy_keys(value, ("c", "a"))
# The eleven palette slots the card reads (src/logic.ts DEFAULT_FILL_COLORS).
# The config schema stays open on purpose so older or extended configs keep
# loading; a package must not carry a key the product never defined (#498).
SUPPORT_FILL_COLOR_KEYS = (
"light_on", "light_off", "light_none", "temp_cold", "temp_ok", "temp_hot",
"lqi_low", "lqi_high", "glow_base", "glow_light", "wall_fill",
)
def _global_settings(value: object) -> dict[str, Any]:
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
if not isinstance(value, dict):
@@ -145,11 +154,13 @@ def _global_settings(value: object) -> dict[str, Any]:
out["show_room_tooltip"] = show_room_tooltip
fill_colors = value.get("fill_colors")
if isinstance(fill_colors, dict):
out["fill_colors"] = {
str(key): _copy_keys(item, ("c", "a"))
for key, item in fill_colors.items()
if isinstance(key, str) and isinstance(item, dict)
palette = {
key: _copy_keys(fill_colors[key], ("c", "a"))
for key in SUPPORT_FILL_COLOR_KEYS
if isinstance(fill_colors.get(key), dict)
}
if palette:
out["fill_colors"] = palette
style = value.get("decor_default_style")
if isinstance(style, dict):
out["decor_default_style"] = _copy_keys(
+8
View File
@@ -2,6 +2,14 @@
## Unreleased
- Attachment uploads no longer count their own in-flight file twice against
the storage quota, so the last file that still fits is accepted instead of
being refused at the boundary. A decor SVG whose local references chain
deeper than 64 hops is refused with a clear "too large" error instead of a
server error. Support packages carry the fill palette only under the eleven
slot names the card defines; any other key in `fill_colors` stays private
([#498](https://github.com/Matysh/houseplan-card/issues/498)).
- Applying a backup import no longer reports "preview expired" after the plan
was already replaced: once both halves of the import are written, the result
and the update events follow the commit even if the preview timed out or was
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Загрузка вложений больше не считает собственный ещё не сохранённый файл
дважды в квоте хранилища: последний файл, который ещё влезает, принимается,
а не отклоняется на границе. SVG-декор с цепочкой локальных ссылок глубже 64
переходов отклоняется понятной ошибкой «слишком большой» вместо ошибки
сервера. Пакет поддержки несёт палитру заливок только под одиннадцатью
именами слотов карточки; любой другой ключ в `fill_colors` остаётся дома
([#498](https://github.com/Matysh/houseplan-card/issues/498)).
- Применение импорта из резервной копии больше не отвечает «preview expired»
после того, как план уже заменён: когда обе половины импорта записаны, ответ
и события обновления следуют за записью, даже если срок предпросмотра истёк
+3 -2
View File
@@ -15,8 +15,9 @@ off by default. If selected, the integration sends the exact canonical bytes
shown in the preview together with their size and SHA-256.
The package is constructed field by field. It contains plan geometry and
dimensions, safe display settings, structural counts, validation/repair
families and bounded browser/registry capability enums. Space, room, wall,
dimensions, safe display settings (the fill palette only by the eleven slot
names the card defines; any other key is dropped), structural counts,
validation/repair families and bounded browser/registry capability enums. Space, room, wall,
opening, marker and binding references receive random package-local names.
It excludes original names and text, Home Assistant installation/location,
+98
View File
@@ -8087,6 +8087,104 @@ const MUTANT_DEFINITIONS = [
+ ' # The store is the durable authority (#335): a drop that\n',
}],
},
{
id: 'quota-counts-the-staged-upload-twice',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_upload_accepts_the_last_bytes '
+ 'tests_backend/test_ha_upload.py',
because: 'the staged .upload-* already lives under files_root; charging it as stored usage '
+ 'and as incoming refuses the last file that still fits (#498 AC1)',
patches: [{
file: 'custom_components/houseplan/http_api.py',
find: ' MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,\n',
replace: ' MAX_FILES_BYTES, MAX_FILES_COUNT,\n',
}],
},
{
id: 'quota-ignores-foreign-staged-uploads',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_concurrent_uploads_still_count_each_other '
+ 'tests_backend/test_ha_upload.py',
because: 'only the caller\'s own staged file is exempt: skipping every .upload-* would let two '
+ 'concurrent uploads pass a quota neither of them fits alone (#498 AC1)',
patches: [{
file: 'custom_components/houseplan/plans.py',
find: ' if exclude is not None and item == exclude:\n',
replace: ' if item.name.startswith(TMP_PREFIX): # mutant: every staged file is invisible\n',
}],
},
{
id: 'support-palette-copies-any-key',
guard: 'node scripts/backend-test-guard.mjs '
+ 'rich_plan_projection_preserves_safe_structure '
+ 'tests_backend/test_support_package.py',
because: 'the package must carry the fill palette only under the slot names the card defines; '
+ 'a private string used as a key must not leave the installation (#498 AC2)',
patches: [{
file: 'custom_components/houseplan/support_package.py',
find: ' for key in SUPPORT_FILL_COLOR_KEYS\n',
replace: ' for key in fill_colors\n',
}],
},
{
id: 'svg-reference-chain-unbounded',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'a reference chain must stop at MAX_SVG_REF_DEPTH with too_large; an unbounded walk '
+ 'accepts what rendering will choke on (#498 AC3)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' if chain > MAX_SVG_REF_DEPTH:\n',
replace: ' if False: # mutant: no chain limit\n',
}, {
file: 'custom_components/houseplan/decor_assets.py',
find: ' if len(stack) > MAX_SVG_REF_DEPTH:\n',
replace: ' if False: # mutant: no stack limit\n',
}],
},
{
id: 'svg-reference-depth-per-start-not-per-chain',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'the limit is the longest chain through a node; measuring only the stack of the '
+ 'traversal that reached it first lets a hostile id order cut a long chain into short '
+ 'segments (#498 spec review r1)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' stack[-1] = (node_id, children, max(chain, longest[ref] + 1))\n',
replace: ' stack[-1] = (node_id, children, chain) # mutant: forget the memoised chain\n',
}],
},
{
id: 'svg-reference-walk-recursive-again',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'the walk must be iterative: a recursive DFS over a flat 2500-link chain dies with '
+ 'RecursionError, which is not a DecorAssetError and reaches the client as a 500 (#498 AC3)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' _walk_reference_graph(ids, ref_graph)\n',
replace: ' visiting: set[str] = set()\n'
+ ' visited: set[str] = set()\n'
+ '\n'
+ ' def _visit(node_id: str) -> None: # mutant: the old recursive walk\n'
+ ' if node_id in visiting:\n'
+ ' raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")\n'
+ ' if node_id in visited:\n'
+ ' return\n'
+ ' visiting.add(node_id)\n'
+ ' for ref in ref_graph.get(node_id, ()):\n'
+ ' _visit(ref)\n'
+ ' visiting.remove(node_id)\n'
+ ' visited.add(node_id)\n'
+ '\n'
+ ' for node_id in ids:\n'
+ ' _visit(node_id)\n',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+44
View File
@@ -182,6 +182,50 @@ def test_svg_resource_limits_fail_closed() -> None:
validate_asset(payload, "bounded.svg")
def _reference_chain(length: int) -> bytes:
defs = "".join(
f'<linearGradient id="g{index}" href="#g{index + 1}"/>' for index in range(length - 1)
) + f'<linearGradient id="g{length - 1}"/>'
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
f"<defs>{defs}</defs>" '<rect width="1" height="1" fill="url(#g0)"/></svg>'
).encode()
def test_issue_498_flat_reference_chain_is_bounded_not_recursive() -> None:
"""A chain within every #436 bound used to end in RecursionError; now it is a refusal."""
from custom_components.houseplan.decor_assets import MAX_SVG_REF_DEPTH
with pytest.raises(DecorAssetError, match="reference chain") as deep:
validate_asset(_reference_chain(2500), "chain.svg")
assert deep.value.code == "too_large"
with pytest.raises(DecorAssetError, match="reference chain"):
validate_asset(_reference_chain(MAX_SVG_REF_DEPTH + 1), "chain.svg")
assert validate_asset(_reference_chain(MAX_SVG_REF_DEPTH), "chain.svg").mime == "image/svg+xml"
# Hostile id order (spec review r1): sorted() starts at the tail of the chain,
# so a walk that measures stack height per start would see segments of one.
hostile = "".join(
f'<linearGradient id="n{index:04d}" href="#n{index - 1:04d}"/>'
for index in range(MAX_SVG_REF_DEPTH + 1, 1, -1)
) + '<linearGradient id="n0001"/>'
reversed_chain = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
f"<defs>{hostile}</defs>" f'<rect width="1" height="1" fill="url(#n{MAX_SVG_REF_DEPTH + 1:04d})"/></svg>'
).encode()
with pytest.raises(DecorAssetError, match="reference chain"):
validate_asset(reversed_chain, "hostile.svg")
cycle = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><defs>'
b'<linearGradient id="a" href="#b"/><linearGradient id="b" href="#a"/>'
b'</defs><rect width="1" height="1" fill="url(#a)"/></svg>'
)
with pytest.raises(DecorAssetError, match="cyclic") as looped:
validate_asset(cycle, "cycle.svg")
assert looped.value.code == "invalid_image"
@pytest.mark.parametrize("attribute", [
'opacity="NaN"', 'opacity="1.1"', 'stop-opacity="101%"', 'offset="-0.1"',
])
+114
View File
@@ -62,3 +62,117 @@ async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: Clie
path = body["url"].split("?", 1)[0]
assert all(seg != ".." for seg in path.split("/"))
assert path.startswith("/api/houseplan/content/files/")
def _pdf_form(name: str, size: int, marker: str = "m1") -> FormData:
fd = FormData()
fd.add_field("marker_id", marker)
fd.add_field("file", b"%PDF-" + b"x" * (size - 5), filename=name, content_type="application/pdf")
return fd
async def test_issue_498_upload_accepts_the_last_bytes_and_the_last_file_of_the_quota(
hass: HomeAssistant, hass_client: ClientSessionGenerator, monkeypatch,
) -> None:
"""#498 AC1: exact byte and count boundaries pass; one more of either is refused."""
from custom_components.houseplan import http_api as hp_http
await _setup(hass)
client = await hass_client()
monkeypatch.setattr(hp_http, "MAX_FILES_BYTES", 1000)
monkeypatch.setattr(hp_http, "MAX_FILES_COUNT", 2)
first = await client.post("/api/houseplan/upload", data=_pdf_form("a.pdf", 600))
assert first.status == 200, await first.text()
exact = await client.post("/api/houseplan/upload", data=_pdf_form("b.pdf", 400))
assert exact.status == 200, await exact.text()
over = await client.post("/api/houseplan/upload", data=_pdf_form("c.pdf", 1))
assert over.status == 507
assert (await over.json())["error"] == "too_many_files"
monkeypatch.setattr(hp_http, "MAX_FILES_COUNT", 3)
over_bytes = await client.post("/api/houseplan/upload", data=_pdf_form("c.pdf", 6))
assert over_bytes.status == 507
assert (await over_bytes.json())["error"] == "quota_exceeded"
from pathlib import Path
from custom_components.houseplan.const import FILES_DIR
from custom_components.houseplan.plans import TMP_PREFIX
root = Path(hass.config.path(FILES_DIR))
assert not list(root.glob(TMP_PREFIX + "*")), "no staged file may outlive its request"
assert sorted(p.name for p in (root / "m1").iterdir()) == ["a.pdf", "b.pdf"]
async def test_issue_498_concurrent_uploads_still_count_each_other(
hass: HomeAssistant, hass_client: ClientSessionGenerator, monkeypatch,
) -> None:
"""#498 AC1: excluding one's own staged file must not hide the neighbour's.
Both uploads are held at the quota check while both staged files exist. Each
sees the other's `.upload-*` as usage, so together they cannot exceed the
quota; a check that ignored every staged file would promote both.
"""
import asyncio
import threading
from custom_components.houseplan import http_api as hp_http
from custom_components.houseplan import plans as hp_plans
await _setup(hass)
client = await hass_client()
monkeypatch.setattr(hp_http, "MAX_FILES_BYTES", 1000)
real_check = hp_plans.check_quota
barrier = threading.Barrier(2, timeout=5)
def both_staged_check(*args, **kwargs):
barrier.wait()
return real_check(*args, **kwargs)
monkeypatch.setattr(hp_http, "check_quota", both_staged_check)
responses = await asyncio.gather(
client.post("/api/houseplan/upload", data=_pdf_form("a.pdf", 600)),
client.post("/api/houseplan/upload", data=_pdf_form("b.pdf", 600)),
)
statuses = sorted(response.status for response in responses)
assert statuses != [200, 200], "1200 bytes would be stored against a 1000-byte quota"
assert all(status in (200, 507) for status in statuses), [await r.text() for r in responses]
from pathlib import Path
from custom_components.houseplan.const import FILES_DIR
root = Path(hass.config.path(FILES_DIR))
assert not list(root.glob(hp_plans.TMP_PREFIX + "*"))
stored = sum(p.stat().st_size for p in (root / "m1").iterdir()) if (root / "m1").is_dir() else 0
assert stored <= 1000
def _svg_chain(length: int) -> bytes:
defs = "".join(
f'<linearGradient id="g{index}" href="#g{index + 1}"/>' for index in range(length - 1)
) + f'<linearGradient id="g{length - 1}"/>'
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
f"<defs>{defs}</defs>" '<rect width="1" height="1" fill="url(#g0)"/></svg>'
).encode()
async def test_issue_498_decor_upload_refuses_a_deep_reference_chain_with_a_code_not_a_500(
hass: HomeAssistant, hass_client: ClientSessionGenerator,
) -> None:
"""#498 AC3: a flat chain of thousands of hrefs is a bounded refusal at the endpoint."""
await _setup(hass)
client = await hass_client()
fd = FormData()
fd.add_field("file", _svg_chain(2500), filename="chain.svg", content_type="image/svg+xml")
response = await client.post("/api/houseplan/assets/upload", data=fd)
assert response.status == 413, await response.text()
assert (await response.json())["error"] == "too_large"
ok = FormData()
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()
+29 -2
View File
@@ -220,7 +220,10 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
"enabled": True,
"z2m_base_topics": ["private/site/zigbee2mqtt"],
},
"fill_colors": {"warm": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}},
"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",
},
@@ -309,7 +312,8 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
space = plan["spaces"][0]
assert package["versions"]["card"] == "unknown"
assert plan["settings"]["fill_colors"] == {"warm": {"a": 0.5, "c": "#ffaa00"}}
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
@@ -407,3 +411,26 @@ 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}},
}
+30
View File
@@ -1522,6 +1522,36 @@ def test_check_quota_counts_the_whole_store_not_one_request(tmp_path):
assert e.value.reason == "too_many_files"
def test_issue_498_check_quota_excludes_the_staged_upload_itself(tmp_path):
"""The staged `.upload-*` already sits under the root; its size arrives as `incoming`."""
d = tmp_path / "files"
(d / "m1").mkdir(parents=True)
(d / "m1" / "a.pdf").write_bytes(b"x" * 600)
staged = d / (plans.TMP_PREFIX + "own")
staged.write_bytes(b"y" * 300)
# Without the exclusion the same 300 bytes are charged twice: 600 + 300 + 300 > 1000.
with pytest.raises(plans.QuotaError) as doubled:
plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10)
assert doubled.value.reason == "quota_exceeded"
plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10, exclude=staged) # 900 fits
plans.check_quota(d, staged.stat().st_size, max_bytes=900, max_files=2, exclude=staged) # exact bytes and count
with pytest.raises(plans.QuotaError) as bytes_over:
plans.check_quota(d, staged.stat().st_size, max_bytes=899, max_files=2, exclude=staged)
assert bytes_over.value.reason == "quota_exceeded"
with pytest.raises(plans.QuotaError) as files_over:
plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=1, exclude=staged)
assert files_over.value.reason == "too_many_files"
# Somebody else's staged upload is about to become an attachment: it counts.
(d / (plans.TMP_PREFIX + "other")).write_bytes(b"z" * 200)
with pytest.raises(plans.QuotaError) as shared:
plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10, exclude=staged)
assert shared.value.reason == "quota_exceeded"
assert plans.dir_usage(d, exclude=staged) == (800, 2)
def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path):
d = tmp_path / "files"
(d / "m1").mkdir(parents=True)