mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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.
79 lines
3.5 KiB
JavaScript
79 lines
3.5 KiB
JavaScript
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
|
|
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
|
|
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
|
|
// иначе на настенном планшете «лишние» записи протухают навсегда.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
const { page, browser } = await launch();
|
|
const res = await page.evaluate(async () => {
|
|
const out = {};
|
|
const c = window.__card;
|
|
const base = c.hass.callWS;
|
|
const batchSizes = [];
|
|
let round = 0;
|
|
|
|
c.hass = { ...c.hass, callWS: async (m) => {
|
|
if (m.type === 'houseplan/content/sign') {
|
|
batchSizes.push(m.paths.length);
|
|
const urls = {};
|
|
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
|
|
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
|
|
return { urls };
|
|
}
|
|
return base(m);
|
|
} };
|
|
|
|
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
|
|
const pdfs = [];
|
|
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
|
|
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
|
|
c._cfgEpoch++;
|
|
|
|
round = 1;
|
|
for (const p of pdfs) c._display(p.url);
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
out.firstBatches = [...batchSizes];
|
|
out.signedAfterFirst = Object.keys(c._signer.entries).length;
|
|
|
|
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
|
|
batchSizes.length = 0;
|
|
round = 2;
|
|
c._resign();
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
out.resignBatches = [...batchSizes];
|
|
const vals = Object.values(c._signer.entries).map((v) => v.url);
|
|
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
|
|
|
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
|
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
|
|
c._cfgEpoch++;
|
|
batchSizes.length = 0;
|
|
round = 3;
|
|
c._resign();
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
out.prunedTo = Object.keys(c._signer.entries).length;
|
|
out.pruneBatches = [...batchSizes];
|
|
|
|
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
|
const one = pdfs[0].url;
|
|
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._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
|
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
|
return out;
|
|
});
|
|
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
|
checkAll(res, {
|
|
firstBatches: [200, 1],
|
|
signedAfterFirst: 201,
|
|
resignBatches: [200, 1],
|
|
allRefreshed: true,
|
|
prunedTo: 5,
|
|
pruneBatches: [5],
|
|
expiredNotServed: true,
|
|
expiredDropped: true,
|
|
agingStillServed: true,
|
|
});
|
|
await finish(browser);
|