mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.44.4: audit follow-up B2, B5, L4 sub-item
B2: the HTTP upload view failed OPEN when the config entry was unavailable while the WS path failed closed — both now share one may_write() policy helper (new auth.py) that denies non-admins when the policy cannot be read. B5: _finite now guards room rects, polygon vertices, view_box and opening coordinates, not just layout positions; the declared MAX_OPENINGS cap is finally enforced. L4 (sub-item): every drag pipeline captures the pointer through the tolerant helper (an inactive pointerId used to kill device/label/resize drags); decor shapes gained a bounds clamp so they cannot be dragged far outside the plan and persisted there. +2 backend tests (16); both changelogs updated in this commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return is_admin if admin_only else True
|
||||
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.44.2"
|
||||
VERSION = "1.44.4"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ except ImportError: # older HA versions
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .store import get_entry
|
||||
from .auth import may_write
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -95,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.44.2"
|
||||
"version": "1.44.4"
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -102,10 +102,10 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -161,17 +161,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): [
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
@@ -181,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
|
||||
@@ -17,6 +17,7 @@ from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
@@ -56,18 +57,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write?
|
||||
|
||||
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||
which never touches the runtime helper, accepted uploads in that window.
|
||||
"""
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return bool(getattr(connection.user, "is_admin", False))
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
"""May this connection write? Thin wrapper over the shared policy."""
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
|
||||
- **One authorization policy (B2).** The HTTP upload view still failed **open**
|
||||
when the config entry was unavailable while the WebSocket path failed closed —
|
||||
the two had drifted apart. Both now call the same `may_write` helper, which
|
||||
denies non-admins whenever the policy cannot be read.
|
||||
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
|
||||
guarded only layout positions; room rects, polygon vertices, `view_box` and
|
||||
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
|
||||
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
|
||||
wired in — the openings list was unbounded.
|
||||
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
|
||||
now used by every drag pipeline (device, label, resize), not just openings —
|
||||
an inactive pointer id could kill a drag outright. Decor shapes gained a
|
||||
bounds clamp: they can no longer be dragged far outside the plan and saved
|
||||
there.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
|
||||
- **The authenticated content endpoint had no working browser path.** v1.43.0
|
||||
closed the security hole correctly, but Home Assistant authenticates HTTP
|
||||
requests by a Bearer header or an `authSig` signed path — and an SVG
|
||||
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
|
||||
links returned **401** on a real dashboard (reproduced live before the fix).
|
||||
The card now asks the backend to sign what it displays
|
||||
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
|
||||
for our own endpoint), re-renders when signatures arrive, and refreshes them
|
||||
every 12 hours so wall tablets keep working. A backend test fetches a signed
|
||||
url **without** an Authorization header and asserts 200, and 401 without the
|
||||
signature.
|
||||
|
||||
|
||||
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
||||
|
||||
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
|
||||
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
|
||||
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
|
||||
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
|
||||
который в неопределённой ситуации пропускает только администраторов.
|
||||
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
|
||||
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
|
||||
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
|
||||
записи превращается в `null` и необратимо портит геометрию. Ограничение
|
||||
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
|
||||
оставался безразмерным.
|
||||
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
|
||||
`setPointerCapture` теперь используется во всех сценариях перетаскивания
|
||||
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
|
||||
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
|
||||
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
|
||||
сохранить там.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
|
||||
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
|
||||
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
|
||||
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
|
||||
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
|
||||
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
|
||||
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
|
||||
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
|
||||
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
|
||||
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
|
||||
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
|
||||
заголовка авторизации и проверяет 200, а без подписи — 401.
|
||||
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
|
||||
@@ -50,6 +50,21 @@
|
||||
never silently linked); urls are rewritten only for confirmed copies
|
||||
[auto: unit logic.test + tests_backend]
|
||||
|
||||
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
|
||||
dashboard with an uploaded plan — the background renders and a manual link
|
||||
opens; DevTools shows /api/houseplan/content/... returning 200 via a
|
||||
signed url, while the same url without authSig returns 401
|
||||
[auto: tests_backend + manual]
|
||||
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
|
||||
write use the same `may_write`, which denies non-admins when the config
|
||||
entry is unavailable [auto: tests_backend]
|
||||
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
|
||||
rects, polygon vertices, view_box and openings — not only in layout; the
|
||||
openings list honours MAX_OPENINGS [auto: tests_backend]
|
||||
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
|
||||
pointer through the tolerant helper; decor shapes cannot be dragged more
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+35
-9
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.44.2';
|
||||
const CARD_VERSION = '1.44.4';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -90,6 +90,22 @@ const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the pointer for a drag, tolerating an inactive pointerId.
|
||||
*
|
||||
* `setPointerCapture` throws for synthetic events and for pointers some
|
||||
* browsers consider gone; that killed a drag outright. The opening pipeline
|
||||
* was hardened for this, the device/label/resize ones were not (audit
|
||||
* follow-up L4 sub-item) — now they all go through here.
|
||||
*/
|
||||
const capturePointer = (ev: PointerEvent): void => {
|
||||
try {
|
||||
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
|
||||
} catch {
|
||||
/* an inactive pointerId must never kill the drag */
|
||||
}
|
||||
};
|
||||
|
||||
class HouseplanCard extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: CardConfig;
|
||||
@@ -1358,7 +1374,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._pos(d);
|
||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -1701,7 +1717,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
@@ -1761,15 +1777,25 @@ class HouseplanCard extends LitElement {
|
||||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||||
pid: ev.pointerId, moved: false,
|
||||
};
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||
const m = this._decorMove!;
|
||||
const p = this._svgPoint(ev);
|
||||
const g = this._gridPitch;
|
||||
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
|
||||
// a shape could be dragged far outside the viewBox and persisted there.
|
||||
const o = m.orig;
|
||||
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
|
||||
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
|
||||
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
|
||||
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
|
||||
const lim = 0.25; // a quarter of the plan may hang outside, no more
|
||||
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
|
||||
dy = Math.max(-curY - lim, Math.min(1 + lim - curY - h, dy));
|
||||
if (dx || dy) m.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
@@ -2080,7 +2106,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
(ev.target as Element).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
@@ -3929,7 +3955,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const p = this._labelPos(r, spaceId);
|
||||
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -3975,7 +4001,7 @@ class HouseplanCard extends LitElement {
|
||||
const cy = b.top + b.height / 2;
|
||||
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
||||
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _rlResizeMove(ev: PointerEvent): void {
|
||||
|
||||
@@ -140,3 +140,36 @@ def test_collection_caps():
|
||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA(big)
|
||||
|
||||
|
||||
def test_finite_on_every_coordinate():
|
||||
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||
# view_box
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||
# room rect coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": "Infinity", "y": 0, "w": 1, "h": 1}]}]})
|
||||
# polygon vertices
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, "NaN"], [1, 1]]}]}]})
|
||||
# opening coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": "NaN", "y": 0.5, "angle": 0, "length": 0.1}]}]})
|
||||
# a sane config still validates
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
for i in range(v.MAX_OPENINGS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
Reference in New Issue
Block a user