v1.45.2: hardening from the v1.45.1 review — R4-1, R4-2

R4-1: collecting superseded plan files runs after the configuration is already
durable, but an error listing the directory propagated out of config/set. The
client saw a failure for a revision the server had committed, and its retry
came back as a conflict. collect_plans now reports 0 instead of raising, and
config/set logs and proceeds — the event fires, the revision is returned.

R4-2: the pending set was cleared when a batch went out, not when it came back,
so every render during an in-flight content/sign queued another request: six
calls where one was needed, and unbounded on a socket that is slow rather than
busy. Queued and in-flight are separate states now; a failure backs off (2 s
doubling to 60 s) instead of retrying on the next frame; an in-flight entry
expires after 15 s so a promise that never settles cannot wedge retries; a late
answer after dispose() no longer renders.

Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified
against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1,
no backoff, a late answer rendering after teardown). Backend: a broken
collector still yields a successful save whose revision the next CAS accepts.
Pure collector: a disappearing directory returns 0.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-28 00:13:45 +03:00
parent c749b52a0d
commit 2e2d353b04
21 changed files with 434 additions and 72 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.45.1"
VERSION = "1.45.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.45.1"
"version": "1.45.2"
}
+12 -3
View File
@@ -63,14 +63,23 @@ def collect_plans(
* 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.
Never raises: the configuration is already stored by the time this runs, so
a file-system problem must not turn a durable commit into a failed call.
"""
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()):
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
except OSError as err:
# The directory can vanish or turn unreadable between the check and the
# walk. This is housekeeping running behind a commit that is already
# durable, so it reports "nothing collected" instead of failing (R4-1).
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
return 0
for item in items:
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
+12 -5
View File
@@ -363,11 +363,18 @@ 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"]
)
# 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).
# It is best-effort housekeeping behind an already durable write — a
# failure here must not withhold the event and the success response,
# or the client retries an edit the server has already accepted and
# gets a conflict for its trouble (R4-1).
try:
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded plan files failed")
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)
+12 -4
View File
@@ -51,19 +51,26 @@ const res = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
// иначе нестабильный сокет получал бы по запросу на каждый рендер
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
await new Promise((r) => setTimeout(r, 150));
out.noRetryStorm = signCalls === 1;
// 3) после выдержки повтор проходит
await new Promise((r) => setTimeout(r, 2100));
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls >= 2;
out.retried = signCalls === 2;
// 3) повторный рендер не теряет подпись и не просит её заново
// 4) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
card.requestUpdate(); await card.updateComplete;
out.hrefStable = await href();
out.noExtraSignOnRerender = signCalls === before;
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
@@ -80,6 +87,7 @@ const res = await page.evaluate(async () => {
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
noRetryStorm: true,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
File diff suppressed because one or more lines are too long
+13 -13
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -194,7 +194,7 @@ 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** (reviews R2-2, R3-2). `ContentSigner`
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-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`
@@ -204,6 +204,10 @@ signatures carry the time they were issued — an aging one keeps rendering whil
its replacement is fetched, an expired one is dropped rather than served (it
would 401 and raise a failed-login warning). The cache is pruned to the urls the
live config references, so it cannot grow past the cap through history alone.
Queued and in-flight are distinct states: a render happening while a request is
out must not queue the same url again, a failure backs off rather than retrying
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
promise that never settles cannot block retries forever.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
Collecting superseded plan files runs after the configuration is already
stored, but an error while listing the directory — it can vanish or turn
unreadable between the check and the walk — propagated out of `config/set`.
The client then saw a failure for a revision the server had committed, and its
retry came back as a conflict. The collector now reports "nothing collected"
instead of raising, and `config/set` logs and proceeds: the event fires and
the new revision is returned.
- **One signing request per url instead of one per render (R4-2).** The pending
set was cleared when the batch went out rather than when it came back, so
while a `content/sign` call was in flight every re-render queued another one —
six calls where one was needed, and far worse on a socket that is slow rather
than merely busy. Queued and in-flight are now separate states, a failure
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
request that never settles stops blocking retries after 15 s. A late answer
arriving after the card was torn down no longer triggers a render.
- Tests: eight unit tests for the signer with hand-settled promises (four fail
on v1.45.1), a backend test asserting a broken collector still yields a
successful save with a usable revision, and the pure-collector test extended
to a disappearing directory.
## 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
+23
View File
@@ -6,6 +6,29 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
ревизия возвращается.
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
больше не вызывает перерисовку.
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
тестах чистого сборщика.
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.45.2** 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 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.2** 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 |
+9
View File
@@ -234,6 +234,15 @@ 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]
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
the WebSocket slow or refusing, the card issues ONE sign request per url
and backs off after a failure instead of asking again on every render; a
request that never answers stops blocking retries after 15 s
[auto: unit: signing.test + smoke_space_card_bg]
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
the plans folder unreadable and save the configuration — the save
succeeds, the revision is usable, and the next save does not conflict
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
- [ ] 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.45.1",
"version": "1.45.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -34,7 +34,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.45.1';
const CARD_VERSION = '1.45.2';
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';
+53 -13
View File
@@ -1,5 +1,11 @@
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
/** A request older than this is presumed lost, and the url may be asked again. */
export const SIGN_INFLIGHT_MS = 15000;
/** After a failure, wait before retrying; doubles up to the cap. */
export const SIGN_BACKOFF_MIN_MS = 2000;
export const SIGN_BACKOFF_MAX_MS = 60000;
/**
* Signed urls for the authenticated content endpoint, shared by both cards.
*
@@ -19,14 +25,23 @@ import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from
* 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.
* - a url that is queued or already in flight is not asked for again: renders
* are frequent and a slow socket used to turn every one of them into another
* `content/sign` call (review R4-2). An in-flight entry expires after
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
* forever, and a failure backs off instead of retrying on the next frame.
*/
export class ContentSigner {
private signed: Record<string, { url: string; at: number }> = {};
private pending = new Set<string>();
/** Waiting for the batch timer to fire. */
private queued = new Set<string>();
/** Sent and not settled yet: url -> when the request went out. */
private inFlight = new Map<string, number>();
/** After a failure: when this url may be asked again, and the current delay. */
private retry = new Map<string, { notBefore: number; delay: number }>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
private disposed = false;
/**
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
@@ -36,15 +51,18 @@ export class ContentSigner {
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
start(hass: () => any, referenced: () => Set<string>): void {
this.disposed = false; // an element can be reconnected after a disconnect
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.disposed = true;
this.stopTimer();
clearTimeout(this.batchTimer);
this.pending.clear();
this.queued.clear();
this.inFlight.clear();
}
private stopTimer(): void {
@@ -72,13 +90,18 @@ export class ContentSigner {
}
private request(hass: any, url: string): void {
if (this.pending.has(url) || !hass?.callWS) return;
this.pending.add(url);
if (!hass?.callWS || this.queued.has(url)) return;
const now = this.now();
const sent = this.inFlight.get(url);
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
const back = this.retry.get(url);
if (back && now < back.notBefore) return; // still backing off
this.queued.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();
const paths = [...this.queued];
this.queued.clear();
this.sign(hass, paths);
}, 30);
}
@@ -86,21 +109,32 @@ export class ContentSigner {
private sign(hass: any, paths: string[]): void {
if (!paths.length || !hass?.callWS) return;
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
const sentAt = this.now();
for (const p of batch) this.inFlight.set(p, sentAt);
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
if (!r?.urls) return;
for (const p of batch) this.retry.delete(p);
if (!r?.urls || this.disposed) 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
.catch(() => {
// back off rather than retry on the very next frame: a socket that
// is refusing sign requests would otherwise be hammered per render
const now = this.now();
for (const p of batch) {
const prev = this.retry.get(p)?.delay || 0;
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
this.retry.set(p, { notBefore: now + delay, delay });
}
})
.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);
// release only our own attempt: a later one may have superseded it
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
});
}
}
@@ -117,6 +151,7 @@ export class ContentSigner {
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this.signed = kept;
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
this.sign(hass, Object.keys(kept));
}
@@ -124,4 +159,9 @@ export class ContentSigner {
get entries(): Record<string, { url: string; at: number }> {
return this.signed;
}
/** Test/debug view of what is currently being asked for. */
get inFlightUrls(): string[] {
return [...this.inFlight.keys()];
}
}
+185
View File
@@ -0,0 +1,185 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ContentSigner, SIGN_INFLIGHT_MS, SIGN_BACKOFF_MIN_MS } from '../test-build/signing.js';
import { SIGN_TTL_MS, SIGN_REFRESH_MS } from '../test-build/logic.js';
const URL_A = '/api/houseplan/content/plans/_/f1.tok.svg';
const URL_B = '/api/houseplan/content/files/m1/manual.pdf';
const tick = () => new Promise((r) => setTimeout(r, 45)); // > the 30 ms batch timer
/** hass whose sign call is resolved/rejected by hand. */
function makeHass() {
const calls = [];
const hass = {
callWS(m) {
let settle;
const p = new Promise((res, rej) => { settle = { res, rej }; });
calls.push({ paths: m.paths, ...settle });
return p;
},
};
return { hass, calls };
}
function signer(now = () => Date.now()) {
let updates = 0;
const s = new ContentSigner(() => { updates++; }, now);
return { s, updates: () => updates };
}
test('R4-2: renders during one unresolved request do not multiply it', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
for (let i = 0; i < 6; i++) assert.equal(s.display(hass, URL_A), '');
await tick();
assert.equal(calls.length, 1, 'one batched request');
// more renders while it is still in flight
for (let i = 0; i < 5; i++) s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'still one: the url is in flight');
assert.deepEqual(s.inFlightUrls, [URL_A]);
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
assert.equal(calls.length, 1, 'a resolved signature starts no extra request');
assert.deepEqual(s.inFlightUrls, []);
});
test('R4-2: a rejection frees the url but backs off before retrying', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
calls[0].rej(new Error('socket'));
await tick();
assert.deepEqual(s.inFlightUrls, [], 'released, not wedged');
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'the very next render must not retry');
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'retried once the backoff has passed');
// the second failure waits longer than the first
calls[1].rej(new Error('socket'));
await tick();
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'backoff doubled');
t += SIGN_BACKOFF_MIN_MS * 2;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 3);
calls[2].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
});
test('R4-2: a request that never settles stops blocking after the timeout', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1);
t += SIGN_INFLIGHT_MS - 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'still presumed in flight');
t += 2;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'presumed lost — asked again');
// the FIRST promise finally answers: it must not resurrect a stale in-flight
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
await tick();
assert.deepEqual(s.inFlightUrls, [URL_A], 'only the second attempt is in flight');
});
test('signatures age: fresh, aging, expired', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=ONE' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
assert.equal(calls.length, 1, 'a fresh signature asks for nothing');
t += SIGN_REFRESH_MS + 1; // aging: keep rendering, fetch a replacement
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
await tick();
assert.equal(calls.length, 2);
t += SIGN_TTL_MS; // expired: rendering it would 401
assert.equal(s.display(hass, URL_A), '');
assert.equal(s.entries[URL_A], undefined, 'the dead entry is dropped');
});
test('a non-content url is passed straight through, nothing is signed', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
assert.equal(s.display(hass, '/local/plan.png'), '/local/plan.png');
assert.equal(s.display(hass, ''), '');
assert.equal(s.display(hass, null), '');
await tick();
assert.equal(calls.length, 0);
});
test('legacy urls are normalised before signing', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
s.display(hass, '/houseplan_files/plans/f1.svg');
await tick();
assert.deepEqual(calls[0].paths, ['/api/houseplan/content/plans/_/f1.svg']);
});
test('resign prunes to the referenced set and gives everything a fresh chance', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
s.display(hass, URL_B);
await tick();
calls[0].res({ urls: { [URL_A]: URL_A + '?s=1', [URL_B]: URL_B + '?s=1' } });
await tick();
assert.equal(Object.keys(s.entries).length, 2);
s.resign(hass, new Set([URL_A]));
await tick();
assert.deepEqual(Object.keys(s.entries), [URL_A], 'the unreferenced url is dropped');
assert.deepEqual(calls[1].paths, [URL_A]);
});
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
const { hass, calls } = makeHass();
const { s, updates } = signer();
s.display(hass, URL_A);
await tick();
s.dispose();
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
await tick();
assert.equal(updates(), 0, 'no re-render after teardown');
s.start(() => hass, () => new Set([URL_A]));
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'a reconnected card asks again');
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
await tick();
assert.equal(updates(), 1);
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
s.dispose();
});
+41
View File
@@ -396,6 +396,47 @@ async def test_collection_ignores_files_that_are_not_plans(
assert (plans / "readme").is_file()
async def test_a_failing_collector_does_not_undo_an_accepted_save(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""review R4-1: garbage collection runs behind an already durable write.
If it raised, the client got an error for a revision the store had already
accepted and its retry then failed with `conflict`, because the server had
moved on. The commit stands and the event fires regardless.
"""
from custom_components.houseplan import websocket_api as wsapi
await _setup(hass)
client = await hass_ws_client(hass)
events = []
hass.bus.async_listen("houseplan_config_updated", lambda ev: events.append(ev.data))
def _boom(*_a, **_k):
raise OSError("the plans directory is on fire")
monkeypatch.setattr(wsapi, "collect_plans", _boom)
cfg = await _cfg([{"id": "r5", "plan_url": None}])
ok = await _save(client, cfg, 0)
assert ok["success"], "an accepted revision must be reported as accepted"
rev = ok["result"]["rev"]
await hass.async_block_till_done()
assert events and events[-1]["rev"] == rev, "the update event still fires"
# the store really holds the new revision, and the reported rev is usable
await client.send_json_auto_id({"type": "houseplan/config/get"})
got = await client.receive_json()
assert got["result"]["rev"] == rev
assert [sp["id"] for sp in got["result"]["config"]["spaces"]] == ["r5"]
monkeypatch.undo()
again = await _save(client, cfg, rev)
assert again["success"], "the next CAS on the reported revision goes through"
async def test_content_signed_path_opens_without_a_bearer_header(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
) -> None:
+13
View File
@@ -292,3 +292,16 @@ def test_collect_plans_survives_a_missing_directory(tmp_path):
collect_plans = plans.collect_plans
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monkeypatch):
"""review R4-1: it runs behind a durable commit, so it may only report 0."""
collect_plans = plans.collect_plans
d = tmp_path / "plans"
d.mkdir()
def _boom(self):
raise OSError("gone")
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
+2 -1
View File
@@ -12,6 +12,7 @@
"src/rules.ts",
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts"
"src/space-geometry.ts",
"src/signing.ts"
]
}