fix(plans): загрузка плана по HTTP, предел 8 МБ проверяется до отправки (#617)

План уходил base64 в WebSocket-кадре: файл больше ~3 МиБ давал кадр больше
4 МиБ, HA закрывал сокет до обработчика, и обещанные 8 МБ были недостижимы.

- бэкенд: HouseplanPlanUploadView (POST /api/houseplan/plans/upload), потоковый
  предел MAX_PLAN_BYTES (read_bounded), общий writer store_plan_upload для view
  и ws_plan_set (контракт WS без изменений);
- карточка: stagePlanFile/uploadPlanFile/renderPlanBackdropGuard в
  backdrop-pick.ts для обоих рантаймов; PlanFilePayload хранит Blob вместо b64;
  SVG больше предела — тост при выборе, растр — диалог #39 только с уменьшенной
  копией, копия больше предела не попадает в staging, 413 называет предел;
- i18n toast.plan_too_large, backdrop.over_limit_body (en/ru/de/fr);
  USER-GUIDE ru/en, CHANGELOG ru/en, docs/testing-notes (#617);
- тесты: test/plan-upload-limit.test.mjs, tests_backend/test_plan_upload.py,
  test_ha_upload.py (#617), smoke_plan_upload_limit.mjs; три смока переведены
  с b64/plan/set на blob/fetchWithAuth; мутанты plan-upload-*;
- база монолита: hostRefs +3 (общий хелпер плана вместо двух копий в рантаймах,
  новый тост уменьшенной копии).

Issue: #617
User-Visible: yes
This commit is contained in:
Claude
2026-09-24 19:57:41 +03:00
parent f8e089bdf2
commit 857369b18b
56 changed files with 2077 additions and 232 deletions
+59
View File
@@ -3509,6 +3509,65 @@ const MUTANT_DEFINITIONS = [
+ ' };',
}],
},
{
id: 'plan-upload-client-limit',
guard: 'node demo/smoke_plan_upload_limit.mjs',
because: 'an SVG above the plan limit must be refused at pick time with a toast naming the '
+ 'limit — otherwise it is staged and only fails after Save, or, on an old backend, closes '
+ 'the socket (#617 AC2)',
patches: [{
file: 'src/backdrop-pick.ts',
find: " if (file.size > MAX_PLAN_BYTES) return refuse('toast.plan_too_large', { mb: MAX_PLAN_MB });\n",
replace: ' // mutant: no client-side plan limit\n',
}],
},
{
id: 'plan-upload-guard-original',
guard: 'node demo/smoke_plan_upload_limit.mjs',
because: 'above the plan limit the original cannot be stored — offering «Keep the original» '
+ 'sends a file the server must refuse; only the reduced copy may be offered (#617 AC3)',
patches: [{
file: 'src/backdrop-pick.ts',
find: ' return renderBackdropGuard(host, apply, close, hass, undefined, size <= MAX_PLAN_BYTES, '
+ 'MAX_PLAN_BYTES);',
replace: ' return renderBackdropGuard(host, apply, close, hass, undefined, true, MAX_PLAN_BYTES);',
}],
},
{
id: 'plan-upload-reduced-over-limit-staged',
guard: 'node demo/smoke_plan_upload_limit.mjs',
because: 'a reduced copy of a huge scan can still exceed the plan limit; staging it defers '
+ 'the refusal to a 413 after Save instead of saying so at once (#617 AC3)',
patches: [{
file: 'src/backdrop-pick.ts',
find: ' if (planLimitBytes !== undefined && out.blob.size > planLimitBytes) {',
replace: ' if (planLimitBytes !== undefined && out.blob.size < 0) { // mutant: never over',
}],
},
{
id: 'plan-upload-413-text-dropped',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/plan-upload-limit.test.mjs',
because: 'a 413 must read as «file larger than 8 MB», not «too_large» or «HTTP 413»; the '
+ 'user has to learn the limit from the error (#617 AC5)',
patches: [{
file: 'src/backdrop-pick.ts',
find: " too_large: t('err.too_large', { mb: json.max_mb || MAX_PLAN_MB }),\n",
replace: '',
}],
},
{
id: 'plan-upload-server-bound',
guard: 'python3 -m pytest tests_backend/test_plan_upload.py -q -p no:cacheprovider',
because: 'the streaming bound is inclusive: MAX_PLAN_BYTES passes and one byte more is refused '
+ 'before the body is buffered whole; a bound that slips by a chunk stores an oversized '
+ 'plan (#617 AC4)',
patches: [{
file: 'custom_components/houseplan/plans.py',
find: ' if size > limit:\n return None\n',
replace: ' if size > limit + chunk:\n return None\n',
}],
},
{
id: 'cold-view-vacuum-mapid-delegated',
guard: 'node demo/smoke_cold_view_vacuum.mjs',
+14
View File
@@ -305,6 +305,20 @@ export const SMOKE_LINKS = [
+ 'with alpha preserved, the cancel-only hard dialog, the honest phase-2 failure (toast, '
+ 'clean staging, no silent original upload) and the SVG bypass (#39)',
},
{
symbols: [
'stagePlanFile', 'uploadPlanFile', 'renderPlanBackdropGuard',
'MAX_PLAN_BYTES', 'MAX_PLAN_MB', 'PLAN_UPLOAD_PATH',
],
smokes: [
'smoke_plan_upload_limit.mjs', 'smoke_plan_upload_race.mjs',
'smoke_plan_upload_reject.mjs', 'smoke_backdrop_guard.mjs',
],
because: '#617: the smokes drive _pickPlanFile/_saveSpaceDialog of both runtimes on the '
+ 'production bundle and observe the transport (one multipart POST, zero plan/set), the '
+ 'pre-send limit toast, the reduced-copy-only guard and the 413 text; none of them names '
+ 'the shared helpers, so a name search cannot find them',
},
{
symbols: ['vacMapIdWithFallback', 'vacMapIdFromAttrs', 'readVacTelemetry'],
smokes: ['smoke_cold_view_vacuum.mjs'],