mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2
R3-1 (high): v1.45.0 made the upload safe but left deletion to the client — after a successful save the card asked the backend to remove everything but the file it had just committed. Two open editors cannot be ordered: a delayed request from one deleted the plan the other had just saved, leaving the accepted configuration pointing at nothing, the exact damage copy-on-write was introduced to prevent. houseplan/plan/cleanup is removed. config/set collects inside its own write lock from the two configurations that bracket the commit (plans.collect_plans): a file the old revision referenced and the new one does not is superseded and goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a fresh one may belong to a transaction that has not committed yet. The collector lives in a pure module so it can be reasoned about and unit-tested without the HA harness. R3-2: houseplan-space-card signed its plan url and threw the result away — getCardSize() mutated a throwaway model while render() rebuilt its own from the config, so the <image> requested the protected path and got 401 on every render. Both cards now share ContentSigner (src/signing.ts), which also gives the static card batching, expiry handling and periodic re-signing. is released in finally: one failed request no longer wedges a url for the life of the page. Tests: five backend interleaving cases from the report, six unit tests for the pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build: the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22 backend-pure. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -16,9 +16,15 @@ CONTENT_URL = "/api/houseplan/content"
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.0"
|
||||
VERSION = "1.45.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.45.0"
|
||||
"version": "1.45.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may delete a plan file, and when, is a correctness question rather than a
|
||||
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
||||
because it is the part that has to be reasoned about and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import PLAN_EXTENSIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
"""
|
||||
if not plans_dir.is_dir():
|
||||
return 0
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if not superseded and not stale:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -19,10 +19,11 @@ from .const import (
|
||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_plans
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +40,6 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -321,6 +321,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -362,6 +363,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1)
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
@@ -406,8 +412,10 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# `houseplan/plan/cleanup` only after its config CAS succeeds; a crash in
|
||||
# between leaves an orphan file that the next successful save removes.
|
||||
# nothing here; the superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
@@ -422,61 +430,3 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _plan_files(plans_dir: Path, space_id: str) -> list[Path]:
|
||||
"""Every plan file belonging to a space: the legacy flat name and versioned ones."""
|
||||
out: list[Path] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in plans_dir.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
parts = item.name.split(".")
|
||||
# "<space>.<ext>" (legacy) or "<space>.<token>.<ext>"
|
||||
if len(parts) in (2, 3) and parts[0] == space_id and parts[-1].lower() in PLAN_EXTENSIONS:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/cleanup",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("keep"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Drop superseded plan files for a space (review R2-1).
|
||||
|
||||
Called by the card ONLY after the config write that references `keep` has
|
||||
been accepted. Until then every previous file is still on disk, which is
|
||||
what makes a rejected save harmless. Deleting nothing is always a safe
|
||||
outcome here — the orphans are bounded by one per rejected upload and are
|
||||
collected by the next successful one.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may manage plans")
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
keep = sanitize_filename(msg["keep"])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _clean() -> int:
|
||||
removed = 0
|
||||
for item in _plan_files(plans_dir, space_id):
|
||||
if item.name == keep:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err: # noqa: PERF203 — a stuck file must not fail the save
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_clean)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Граница транзакции загрузки подложки (ревью R2-1).
|
||||
// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
|
||||
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
|
||||
// сохранение не имеет права трогать сохранённый план. Проверяем контракт со
|
||||
// стороны карточки: удаление старых файлов (houseplan/plan/cleanup) уходит
|
||||
// ТОЛЬКО после принятого config/set — и никогда после отказа.
|
||||
// сохранение не имеет права трогать сохранённый план. Со стороны карточки
|
||||
// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
|
||||
// делает сам config/set под блокировкой (клиент не может упорядочить свою
|
||||
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
|
||||
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -18,7 +20,8 @@ const res = await page.evaluate(async () => {
|
||||
uploads++;
|
||||
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
|
||||
}
|
||||
if (m.type === 'houseplan/plan/cleanup') { cleanups.push(m); return { ok: true, removed: 1 }; }
|
||||
// любая команда удаления файлов от клиента — нарушение контракта R3-1
|
||||
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
c.__sent = m.config; return { ok: true, rev: 77 };
|
||||
@@ -48,11 +51,11 @@ const res = await page.evaluate(async () => {
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
await attach();
|
||||
out.cleanupsAfterAccept = cleanups.length;
|
||||
out.cleanupSpace = cleanups[0]?.space_id;
|
||||
out.cleanupKeep = cleanups[0]?.keep;
|
||||
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.savedPlanUrl = f1?.plan_url;
|
||||
out.keepMatchesSavedUrl = !!f1 && f1.plan_url.endsWith('/' + cleanups[0]?.keep);
|
||||
out.dialogClosedOnAccept = c._spaceDialog === null;
|
||||
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
|
||||
out.versionedNames = uploads === 2;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||
@@ -60,10 +63,9 @@ checkAll(res, {
|
||||
uploadedOnReject: true,
|
||||
cleanupsAfterReject: 0,
|
||||
dialogStaysOpenOnReject: true,
|
||||
cleanupsAfterAccept: 1,
|
||||
cleanupSpace: 'f1',
|
||||
cleanupKeep: 'f1.tok2.png',
|
||||
cleanupsAfterAccept: 0,
|
||||
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
|
||||
keepMatchesSavedUrl: true,
|
||||
dialogClosedOnAccept: true,
|
||||
versionedNames: true,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
|
||||
for (const p of pdfs) c._display(p.url);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.firstBatches = [...batchSizes];
|
||||
out.signedAfterFirst = Object.keys(c._signed).length;
|
||||
out.signedAfterFirst = Object.keys(c._signer.entries).length;
|
||||
|
||||
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
|
||||
batchSizes.length = 0;
|
||||
@@ -40,7 +40,7 @@ const res = await page.evaluate(async () => {
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.resignBatches = [...batchSizes];
|
||||
const vals = Object.values(c._signed).map((v) => v.url);
|
||||
const vals = Object.values(c._signer.entries).map((v) => v.url);
|
||||
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
||||
|
||||
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
||||
@@ -50,15 +50,16 @@ const res = await page.evaluate(async () => {
|
||||
round = 3;
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.prunedTo = Object.keys(c._signed).length;
|
||||
out.prunedTo = Object.keys(c._signer.entries).length;
|
||||
out.pruneBatches = [...batchSizes];
|
||||
|
||||
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
||||
const one = pdfs[0].url;
|
||||
c._signed = { ...c._signed, [one]: { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 } };
|
||||
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
out.expiredNotServed = c._display(one) === '';
|
||||
out.expiredDropped = c._signer.entries[one] === undefined;
|
||||
// а стареющая, но ещё живая — отдаётся, пока едет замена
|
||||
c._signed = { ...c._signed, [one]: { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 } };
|
||||
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
||||
return out;
|
||||
});
|
||||
@@ -71,6 +72,7 @@ checkAll(res, {
|
||||
prunedTo: 5,
|
||||
pruneBatches: [5],
|
||||
expiredNotServed: true,
|
||||
expiredDropped: true,
|
||||
agingStillServed: true,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
|
||||
// результат — getCardSize() правил временную модель, а render() строил свою
|
||||
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
|
||||
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
|
||||
// подложка на защищённом эндпоинте + управляемый ответ на подпись
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
|
||||
let signCalls = 0;
|
||||
let failFirst = true;
|
||||
const requestedHrefs = [];
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
if (failFirst && signCalls === 1) throw new Error('ws down');
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
|
||||
return { urls };
|
||||
}
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
|
||||
const stage = async () => {
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
return card.renderRoot.querySelector('.hp-static-stage svg image');
|
||||
};
|
||||
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
|
||||
|
||||
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
|
||||
await stage();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
|
||||
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls >= 2;
|
||||
|
||||
// 3) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefStable = await href();
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenExpired = await href();
|
||||
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenAging = await href();
|
||||
|
||||
// ни один сырой (неподписанный) путь не должен уходить в сеть
|
||||
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
|
||||
out.noRawHrefEver = !requestedHrefs.includes(raw);
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
noExtraSignOnRerender: true,
|
||||
hrefWhenExpired: null,
|
||||
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
|
||||
noRawHrefEver: true,
|
||||
});
|
||||
await finish(browser);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+23
-23
File diff suppressed because one or more lines are too long
+17
-10
@@ -178,19 +178,26 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plan/cleanup` | `space_id`, `keep` | `{ok, removed}` — call ONLY after the config write referencing `keep` was accepted |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**Plan uploads are copy-on-write** (review R2-1). The file system is not part
|
||||
of the config's optimistic-locking transaction, so nothing that is currently
|
||||
referenced may be overwritten or deleted before the config CAS succeeds: the
|
||||
upload writes a new versioned name, the card commits the url, and only then
|
||||
asks for `plan/cleanup`. A crash between the two leaves one orphan file, which
|
||||
the next successful upload removes. The `.` between id and token is load-bearing
|
||||
— a space id cannot contain one, so `<space>.<token>.<ext>` can never be
|
||||
confused with the files of a space whose name merely starts the same way.
|
||||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||||
(reviews R2-1, R3-1). The file system is not part of the config's
|
||||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||||
deleted before the CAS succeeds: the upload writes a new versioned name and
|
||||
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
|
||||
request cannot be ordered against another client's commit, and a delayed one
|
||||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||||
write lock, from the pair of configurations that bracket the commit
|
||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**Signed content urls are batched and aged** (review R2-2). `MAX_SIGN_PATHS`
|
||||
**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||||
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
|
||||
request there and says nothing about the rest, so the card must chunk. Cached
|
||||
signatures carry the time they were issued — an aging one keeps rendering while
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
|
||||
- **Collecting old plan files moved into the config transaction (R3-1, high).**
|
||||
v1.45.0 made the upload safe but handed the deletion to the client: after a
|
||||
successful save the card asked the backend to remove everything except the
|
||||
file it had just committed. Two open editors could not be ordered — a delayed
|
||||
request from one client deleted the plan the other had just saved, and the
|
||||
accepted configuration was left pointing at nothing, which is the exact damage
|
||||
copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
|
||||
gone. `config/set` now collects inside its own write lock, comparing the
|
||||
configuration it replaced with the one it accepted: a file the old revision
|
||||
referenced and the new one does not is removed, and any other unreferenced
|
||||
upload is left alone until it is an hour old, because a fresh one may belong
|
||||
to a transaction that has not committed yet.
|
||||
- **The static space card shows its plan background again (R3-2).** It signed
|
||||
the url and then threw the result away — `getCardSize()` mutated a throwaway
|
||||
model while `render()` rebuilt its own from the config — so the `<image>` kept
|
||||
requesting the protected path and got a 401 on every render. Both cards now
|
||||
share one signer, which also gives the static card the batching, the
|
||||
expiry handling and the periodic re-signing the main card already had. Its
|
||||
pending set is released in `finally`, so a single failed request no longer
|
||||
wedges a url for the life of the page.
|
||||
- New tests: five backend cases for the two-client interleavings from the report
|
||||
(late commit, uncommitted upload, aged orphan, foreign files, rejected save),
|
||||
the collector extracted to a pure module and unit-tested, and
|
||||
`smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
|
||||
raw url in the DOM.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **A rejected save can no longer damage a working plan (R2-1, high).** The
|
||||
plan file was written to its final name — deleting the previous extension on
|
||||
|
||||
@@ -6,6 +6,33 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
|
||||
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
|
||||
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
|
||||
после успешного сохранения карточка просила бэкенд убрать всё, кроме только
|
||||
что закоммиченного файла. Два открытых редактора невозможно упорядочить —
|
||||
задержавшийся запрос одного клиента удалял план, который только что сохранил
|
||||
другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
|
||||
ровно с тем ущербом, ради которого вводился copy-on-write. Команда
|
||||
`houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
|
||||
блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
|
||||
файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
|
||||
любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
|
||||
свежая может принадлежать ещё не завершённой чужой транзакции.
|
||||
- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
|
||||
подписывала URL и выбрасывала результат — `getCardSize()` правил временную
|
||||
модель, а `render()` строил свою заново из конфига, — поэтому `<image>`
|
||||
запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
|
||||
теперь используют один подписыватель, и статическая заодно получила батчи,
|
||||
учёт срока годности и периодическое переподписывание, которые были только у
|
||||
основной. Её множество ожидающих запросов освобождается в `finally`, так что
|
||||
одна неудача больше не блокирует ссылку до конца жизни страницы.
|
||||
- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
|
||||
(поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
|
||||
отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
|
||||
тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
|
||||
v1.45.0, где в DOM попадает сырой URL.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
|
||||
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.45.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.0** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
@@ -234,6 +234,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
|
||||
two tabs, attach a background in each in turn — the plan last saved is the
|
||||
one served, and neither commit deletes the other's file. A rejected upload
|
||||
disappears on a later save, not immediately
|
||||
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
|
||||
test_commit_does_not_collect_another_client_s_uncommitted_upload,
|
||||
test_abandoned_uploads_are_collected_once_old]
|
||||
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
|
||||
dashboard shows the plan image, not an empty stage; the browser never
|
||||
requests the unsigned path and Home Assistant logs no failed login. A
|
||||
failed signing request is retried on the next render
|
||||
[auto: smoke_space_card_bg]
|
||||
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
|
||||
background, make the config write fail (a second tab saving first is
|
||||
enough) — the previously stored plan is still served, with the same or a
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+14
-95
@@ -21,8 +21,9 @@ import {
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
|
||||
referencedContentUrls,
|
||||
} from './logic';
|
||||
import { ContentSigner } from './signing';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
@@ -33,7 +34,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.0';
|
||||
const CARD_VERSION = '1.45.1';
|
||||
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';
|
||||
@@ -366,8 +367,7 @@ class HouseplanCard extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this._keyHandler);
|
||||
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||
clearInterval(this._resignTimer);
|
||||
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
|
||||
this._signer.start(() => this.hass, () => referencedContentUrls(this._serverCfg));
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -381,8 +381,7 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
clearTimeout(this._signTimer);
|
||||
clearInterval(this._resignTimer);
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
@@ -789,85 +788,18 @@ class HouseplanCard extends LitElement {
|
||||
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||
* So the card asks the backend to sign what it is about to display.
|
||||
*/
|
||||
private _signed: Record<string, { url: string; at: number }> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
/** Display url: a signature we hold and still trust, else nothing. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this._signed[u];
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
// Past its lifetime the signature is worthless: serving it would 401 and
|
||||
// raise a failed-login warning, exactly what an unsigned path does.
|
||||
if (age >= SIGN_TTL_MS) delete this._signed[u];
|
||||
else if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
else {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this._requestSignature(u);
|
||||
return hit.url;
|
||||
}
|
||||
this._requestSignature(u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt" for the
|
||||
// viewer's own IP. Callers skip rendering until the signature lands.
|
||||
return '';
|
||||
return this._signer.display(this.hass, url);
|
||||
}
|
||||
|
||||
private _requestSignature(url: string): void {
|
||||
if (this._signPending.has(url) || !this.hass?.callWS) return;
|
||||
this._signPending.add(url);
|
||||
clearTimeout(this._signTimer);
|
||||
// batch: a plan switch asks for several urls in the same tick
|
||||
this._signTimer = window.setTimeout(() => {
|
||||
const paths = [...this._signPending];
|
||||
this._signPending.clear();
|
||||
this._signBatches(paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in batches the backend will actually answer in full. It caps a request
|
||||
* at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
|
||||
* call leaves entries that never get refreshed and silently expire (R2-2).
|
||||
*/
|
||||
private _signBatches(paths: string[]): void {
|
||||
if (!paths.length || !this.hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
const now = Date.now();
|
||||
const next = { ...this._signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at: now };
|
||||
this._signed = next;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign periodically: a wall tablet outlives a signature.
|
||||
* The old urls are kept until the new ones arrive — dropping them first would
|
||||
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
||||
*/
|
||||
private _resignTimer?: number;
|
||||
|
||||
/** Re-sign what the live config still references (wall tablets outlive one). */
|
||||
private _resign(): void {
|
||||
// prune first: an entry for a plan that was replaced months ago must not
|
||||
// consume a slot in the (capped) signing request
|
||||
const live = referencedContentUrls(this._serverCfg);
|
||||
const now = Date.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this._signed)) {
|
||||
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this._signed = kept;
|
||||
this._signBatches(Object.keys(kept));
|
||||
this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
|
||||
}
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -3057,11 +2989,11 @@ class HouseplanCard extends LitElement {
|
||||
label_light: d.labelLight,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
// Nothing to clean up from here: the backend collects the superseded
|
||||
// file inside the same locked transaction that accepted this config
|
||||
// (review R3-1). A cleanup driven from the client could not be ordered
|
||||
// against another client's commit and deleted its freshly saved plan.
|
||||
await this._saveConfigNow();
|
||||
// Only now is the new file authoritative: the config write was accepted,
|
||||
// so the previous plan can go. Before this point nothing on disk was
|
||||
// touched, which is what makes a rejected save harmless (review R2-1).
|
||||
if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
|
||||
this._spaceDialog = null;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
this._regSignature = '';
|
||||
@@ -3138,19 +3070,6 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
|
||||
* user's edit is already saved, and a failed cleanup only leaves a stray file
|
||||
* that the next successful upload collects (review R2-1).
|
||||
*/
|
||||
private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
|
||||
const keep = keepUrl.split('?')[0].split('/').pop();
|
||||
if (!keep || !this.hass?.callWS) return;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ================= FLOORS IMPORT WIZARD =================
|
||||
|
||||
private _startImport(): void {
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
|
||||
* Assistant takes a Bearer header or an `authSig` signed path, and an element
|
||||
* sends neither. So whatever is about to be displayed has to be signed first.
|
||||
*
|
||||
* This used to be implemented twice — and the second copy (houseplan-space-card)
|
||||
* signed correctly but never handed the result to its renderer, so the plan
|
||||
* background asked for the raw protected url and got a 401 on every render
|
||||
* (review R3-2). One implementation, one set of rules:
|
||||
*
|
||||
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
|
||||
* applies (an oversized call comes back partial with no way to tell what was
|
||||
* dropped, and those entries then expire for good);
|
||||
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - `pending` is always released, so one failed request does not wedge a url
|
||||
* forever.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
private pending = new Set<string>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
* @param now injectable clock — the tests need to age a signature
|
||||
*/
|
||||
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
|
||||
this.resignTimer = undefined;
|
||||
}
|
||||
|
||||
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
|
||||
display(hass: any, url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this.signed[u];
|
||||
const age = hit ? this.now() - hit.at : Infinity;
|
||||
if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
if (age < SIGN_TTL_MS) {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this.request(hass, u);
|
||||
return hit.url;
|
||||
}
|
||||
if (hit) delete this.signed[u];
|
||||
this.request(hass, u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt".
|
||||
return '';
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (this.pending.has(url) || !hass?.callWS) return;
|
||||
this.pending.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.pending];
|
||||
this.pending.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => undefined) // a retry happens on the next render
|
||||
.finally(() => {
|
||||
// never leave a url wedged in `pending`: the first failure would
|
||||
// otherwise make it unrequestable for the life of the page (R3-2)
|
||||
for (const p of batch) this.pending.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign what is still in use. A wall tablet outlives a signature, and an
|
||||
* entry for a plan replaced months ago must not consume a slot in the capped
|
||||
* request — so prune to the urls the live config still references.
|
||||
*/
|
||||
resign(hass: any, referenced: Set<string>): void {
|
||||
const now = this.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this.signed)) {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
/** Test/debug view of the cache. */
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
|
||||
import { renderSpaceStatic, spaceModels } from './space-render';
|
||||
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
|
||||
import { t, langOf, type Lang } from './i18n';
|
||||
import { ContentSigner } from './signing';
|
||||
import { referencedContentUrls } from './logic';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// a dashboard on a wall tablet outlives a 24 h signature
|
||||
this._signer.start(() => this.hass, () => this._referenced());
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
this._signer.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -109,8 +114,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
public getCardSize(): number {
|
||||
const models = spaceModels(this._snap?.config || null);
|
||||
// B1 follow-up: the plan <image> needs a signed url in a browser session
|
||||
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
|
||||
const sp = models.find((s) => s.id === this._config?.space);
|
||||
if (sp) {
|
||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||
@@ -123,25 +126,16 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
private _signedBg: Record<string, string> = {};
|
||||
private _signBgPending = new Set<string>();
|
||||
/**
|
||||
* Same signer as the main card (review R3-2). The previous copy here signed
|
||||
* the url and then threw it away: `getCardSize()` mutated a throwaway model
|
||||
* while `render()` rebuilt its own from the config, so the <image> kept
|
||||
* asking for the raw protected path and got a 401 on every render.
|
||||
*/
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
/** Ask the backend for a signed content url and swap it in when it arrives. */
|
||||
private _signBg(bg: { href: string }): void {
|
||||
const raw = bg.href.split('?authSig=')[0];
|
||||
if (!raw.startsWith('/api/houseplan/content/')) return;
|
||||
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
|
||||
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
|
||||
this._signBgPending.add(raw);
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
|
||||
.then((r: any) => {
|
||||
const url = r?.urls?.[raw];
|
||||
if (!url) return;
|
||||
this._signedBg = { ...this._signedBg, [raw]: url };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
private _referenced(): Set<string> {
|
||||
return referencedContentUrls(this._snap?.config);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -159,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
spaceId,
|
||||
iconSize: this._config.icon_size,
|
||||
lang: this._lang,
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
|
||||
+11
-2
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
|
||||
spaceId: string;
|
||||
iconSize?: number;
|
||||
lang: Lang;
|
||||
/**
|
||||
* Resolve a stored content url to what the DOM may actually request — the
|
||||
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
||||
* Returning '' means "not signed yet": the caller must render no <image>
|
||||
* rather than an unsigned one, which would 401 (review R3-2).
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
})
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
|
||||
@@ -106,101 +106,6 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
|
||||
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)
|
||||
# the HA test config dir is shared across a module: start from a known state
|
||||
for stale in plans.glob("s9.*"):
|
||||
stale.unlink()
|
||||
legacy = plans / "s9.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": "s9", "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": "s9", "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": "s9", "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):
|
||||
"""audit B2/T4: with no config entry the policy is unknown — deny writes.
|
||||
|
||||
@@ -278,6 +183,219 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
async def _cfg(spaces: list[dict]) -> dict:
|
||||
"""Minimal accepted configuration with the given spaces."""
|
||||
return {
|
||||
"spaces": [
|
||||
{"id": sp["id"], "title": sp["id"], "plan_url": sp.get("plan_url"),
|
||||
"aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
for sp in spaces
|
||||
],
|
||||
"markers": [],
|
||||
}
|
||||
|
||||
|
||||
async def _save(client, config, expected_rev):
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/config/set", "config": config, "expected_rev": expected_rev}
|
||||
)
|
||||
return await client.receive_json()
|
||||
|
||||
|
||||
async def _upload(client, space_id, data=b"x", ext="png"):
|
||||
import base64 as _b64
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": space_id, "ext": ext,
|
||||
"data": _b64.b64encode(data).decode(),
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
return resp["result"]["url"], resp["result"]["url"].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
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)
|
||||
for stale in plans.glob("s9.*"):
|
||||
stale.unlink()
|
||||
legacy = plans / "s9.svg" # what an older version stored, still referenced
|
||||
legacy.write_bytes(b"<svg>old</svg>")
|
||||
|
||||
url0 = "/api/houseplan/content/plans/_/s9.svg"
|
||||
resp = await _save(client, await _cfg([{"id": "s9", "plan_url": url0}]), 0)
|
||||
rev = resp["result"]["rev"]
|
||||
assert legacy.is_file()
|
||||
|
||||
url1, first = await _upload(client, "s9", b"hello")
|
||||
# config write rejected (stale revision): nothing on disk may change
|
||||
bad = await _save(client, await _cfg([{"id": "s9", "plan_url": url1}]), rev - 1)
|
||||
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
||||
assert legacy.read_bytes() == b"<svg>old</svg>"
|
||||
assert (plans / first).read_bytes() == b"hello"
|
||||
|
||||
# a second attempt neither overwrites the first nor the legacy file
|
||||
url2, second = await _upload(client, "s9", b"world")
|
||||
assert second != first
|
||||
assert (plans / first).read_bytes() == b"hello"
|
||||
assert legacy.is_file()
|
||||
|
||||
# config accepted → the superseded file goes, the referenced one stays.
|
||||
# `first` is a rejected upload: it is young, so it is kept for now.
|
||||
ok = await _save(client, await _cfg([{"id": "s9", "plan_url": url2}]), rev)
|
||||
assert ok["success"]
|
||||
assert (plans / second).read_bytes() == b"world"
|
||||
assert not legacy.exists(), "the superseded plan is collected"
|
||||
assert (plans / first).is_file(), "a fresh unreferenced upload is NOT collected"
|
||||
|
||||
|
||||
async def test_late_commit_of_one_client_never_deletes_another_client_s_plan(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""review R3-1: collection belongs to the commit, not to a client's request.
|
||||
|
||||
With the previous `plan/cleanup(keep=...)` command, this interleaving left
|
||||
the accepted configuration pointing at a file that had just been deleted:
|
||||
|
||||
A: upload PA, config/set(PA) accepted
|
||||
B: upload PB, config/set(PB) accepted
|
||||
A: cleanup(keep=PA) -> removes PB
|
||||
|
||||
Collection now runs inside config/set under the write lock, so a late
|
||||
client cannot express an opinion about a revision it never saw.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
a = await hass_ws_client(hass)
|
||||
b = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r1.*"):
|
||||
stale.unlink()
|
||||
|
||||
url0, p0 = await _upload(a, "r1", b"zero")
|
||||
rev = (await _save(a, await _cfg([{"id": "r1", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
url_a, pa = await _upload(a, "r1", b"aaa")
|
||||
rev_a = (await _save(a, await _cfg([{"id": "r1", "plan_url": url_a}]), rev))["result"]["rev"]
|
||||
assert not (plans / p0).exists(), "P0 was superseded by A"
|
||||
|
||||
url_b, pb = await _upload(b, "r1", b"bbb")
|
||||
ok = await _save(b, await _cfg([{"id": "r1", "plan_url": url_b}]), rev_a)
|
||||
assert ok["success"]
|
||||
|
||||
# the accepted configuration points at PB, and PB is on disk
|
||||
assert (plans / pb).read_bytes() == b"bbb"
|
||||
assert not (plans / pa).exists(), "PA was superseded by B's commit"
|
||||
|
||||
|
||||
async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""review R3-1, second interleaving: B uploads, A commits, then B commits.
|
||||
|
||||
A's commit must not remove PB — B has not written its configuration yet, so
|
||||
PB is unreferenced but belongs to a live transaction. Age is the guard.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
a = await hass_ws_client(hass)
|
||||
b = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r2.*"):
|
||||
stale.unlink()
|
||||
|
||||
url0, _p0 = await _upload(a, "r2", b"zero")
|
||||
rev = (await _save(a, await _cfg([{"id": "r2", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
_url_b, pb = await _upload(b, "r2", b"bbb") # B uploads, does not commit
|
||||
url_a, pa = await _upload(a, "r2", b"aaa")
|
||||
rev_a = (await _save(a, await _cfg([{"id": "r2", "plan_url": url_a}]), rev))["result"]["rev"]
|
||||
assert (plans / pb).is_file(), "an uncommitted upload survives someone else's commit"
|
||||
|
||||
# B now commits on top of A's revision — its file is still there
|
||||
ok = await _save(b, await _cfg([{"id": "r2", "plan_url": "/api/houseplan/content/plans/_/" + pb}]), rev_a)
|
||||
assert ok["success"]
|
||||
assert (plans / pb).read_bytes() == b"bbb"
|
||||
assert not (plans / pa).exists()
|
||||
|
||||
|
||||
async def test_abandoned_uploads_are_collected_once_old(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A rejected upload must not accumulate forever — but only age may free it."""
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r3.*"):
|
||||
stale.unlink()
|
||||
|
||||
url0, p0 = await _upload(client, "r3", b"zero")
|
||||
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
_url, orphan = await _upload(client, "r3", b"abandoned")
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(plans / orphan, (old, old))
|
||||
|
||||
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
|
||||
assert ok["success"]
|
||||
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
|
||||
assert (plans / p0).is_file(), "the referenced plan is never touched"
|
||||
|
||||
|
||||
async def test_collection_ignores_files_that_are_not_plans(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""The plans directory may hold nothing else, but be sure we only take ours."""
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
for name in ("notes.txt", "deep.name.with.dots.png", "readme"):
|
||||
(plans / name).write_bytes(b"x")
|
||||
os.utime(plans / name, (old, old))
|
||||
|
||||
rev = (await _save(client, await _cfg([{"id": "r4", "plan_url": None}]), 0))["result"]["rev"]
|
||||
assert rev
|
||||
assert (plans / "notes.txt").is_file()
|
||||
assert (plans / "deep.name.with.dots.png").is_file()
|
||||
assert (plans / "readme").is_file()
|
||||
|
||||
|
||||
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||
) -> None:
|
||||
|
||||
@@ -15,6 +15,44 @@ v = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(v)
|
||||
|
||||
|
||||
def _load_pure(name):
|
||||
"""Load one pure module of the integration without importing the package.
|
||||
|
||||
custom_components/houseplan/__init__.py pulls in Home Assistant, which the
|
||||
local sandbox does not have; but plans.py is deliberately pure, so a tiny
|
||||
stub package lets it keep its normal relative imports.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
pkg_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "custom_components", "houseplan"
|
||||
)
|
||||
pkg = sys.modules.get("hp_pure")
|
||||
if pkg is None:
|
||||
pkg = types.ModuleType("hp_pure")
|
||||
pkg.__path__ = [pkg_dir]
|
||||
sys.modules["hp_pure"] = pkg
|
||||
for dep in ("const", "validation"):
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{dep}", os.path.join(pkg_dir, f"{dep}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{dep}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{name}", os.path.join(pkg_dir, f"{name}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{name}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
plans = _load_pure("plans")
|
||||
const = importlib.import_module("hp_pure.const")
|
||||
|
||||
|
||||
def test_sanitize_marker_id():
|
||||
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
|
||||
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
|
||||
@@ -173,3 +211,84 @@ def test_openings_cap_enforced():
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
|
||||
# ---------- plan-file collection (review R3-1) ----------
|
||||
|
||||
|
||||
def _plans(tmp_path, names, age=0.0):
|
||||
import os, time
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir(exist_ok=True)
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
if age:
|
||||
t = time.time() - age
|
||||
os.utime(d / n, (t, t))
|
||||
return d
|
||||
|
||||
|
||||
def _cfg(*urls):
|
||||
return {"spaces": [{"id": f"s{i}", "plan_url": u} for i, u in enumerate(urls)]}
|
||||
|
||||
|
||||
def test_plan_basename_and_refs():
|
||||
plan_basename, plan_refs, is_plan_file = plans.plan_basename, plans.plan_refs, plans.is_plan_file
|
||||
|
||||
assert plan_basename("/api/houseplan/content/plans/_/f1.abc.png?v=7") == "f1.abc.png"
|
||||
assert plan_basename("/houseplan_files/plans/f1.svg") == "f1.svg"
|
||||
assert plan_basename(None) == "" and plan_basename("") == "" and plan_basename(7) == ""
|
||||
assert plan_refs(None) == set() and plan_refs({}) == set()
|
||||
assert plan_refs(_cfg("/p/a.png", None, "/p/b.svg")) == {"a.png", "b.svg"}
|
||||
assert is_plan_file("f1.svg") and is_plan_file("f1.tok.png")
|
||||
assert not is_plan_file("notes.txt") and not is_plan_file("readme")
|
||||
assert not is_plan_file("deep.name.with.dots.png") # 4 parts: not ours
|
||||
|
||||
|
||||
def test_collect_plans_removes_only_the_superseded_file(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.old.png", "f1.new.png", "f2.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.old.png", "/p/f2.png"), _cfg("/p/f1.new.png", "/p/f2.png"))
|
||||
assert removed == 1
|
||||
assert not (d / "f1.old.png").exists()
|
||||
assert (d / "f1.new.png").is_file() and (d / "f2.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
"""Another client may be mid-transaction: its file is unreferenced but young."""
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.committed.png", "f1.inflight.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.committed.png"), _cfg("/p/f1.committed.png"))
|
||||
assert removed == 0
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
|
||||
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
|
||||
assert removed == 1
|
||||
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
old = PLAN_ORPHAN_TTL_S + 60
|
||||
d = _plans(tmp_path, ["f1.png", "notes.txt", "readme", "deep.name.with.dots.png"], age=old)
|
||||
removed = collect_plans(d, _cfg("/p/f1.png"), _cfg("/p/f1.png"))
|
||||
assert removed == 0
|
||||
for n in ("f1.png", "notes.txt", "readme", "deep.name.with.dots.png"):
|
||||
assert (d / n).is_file()
|
||||
|
||||
|
||||
def test_collect_plans_survives_a_missing_directory(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
|
||||
|
||||
Reference in New Issue
Block a user