23 KiB
CODE-REVIEW-626-r1
Issue: #626 · этап: code · заход: r1 · материал: 552504b834c73484779720a356150aaee92afc28
(рабочая копия уже на нём; git rev-parse HEAD = 552504b834c73484779720a356150aaee92afc28)
Ветка: issue/626-acl-policy · база: origin/dev (merge-base d5df838976f6be55d30048510773b6079dce50c1)
Validate на материале: success, workflow_dispatch,
https://github.com/Matysh/houseplan-card/actions/runs/36125882168
Скоуп
ТЗ (issue body, ревью SPEC-REVIEW-626-r1, зелёный) требует:
may_write()— единственный источник writer-прав для WS/HTTP: admin всегда writer;admin_only=true— только admin;admin_only=false— обычный non-admin пишет,system-read-only— нет; неизвестный/неполный user — fail-closed.houseplan/plans/listзакрыт тем же guard-ом, стандартная ошибкаunauthorized, без сканирования каталога для не-писателя.houseplan/trail/getрекурсивно очищен от ключаsourceбез мутацииTrailBook.- Документация (
USER-GUIDE.ru.md§2/§20,SCOPE.md,ARCHITECTURE.md) и оба changelog синхронизированы с контрактом.
Не-скоуп (подтверждаю по диффу — не тронуто): entity-level фильтрация
config/get/layout/get, virtual_light/toggle, ACL радаров, content
signing, assets/resolve/assets/list контракт (кроме использования
may_write, что уже было).
Диапазон материала: 3 продуктовых коммита с корректными трейлерами
(Issue: #626, User-Visible: yes на обоих user-visible коммитах):
552504b8 fix: preserve administrator write access
e6987459 fix: respect Home Assistant read-only ACL
25290527 docs: review document for #626 (артефакт ревью ТЗ, не код)
552504b8 — самостоятельно найденный и исправленный автором дефект второго
коммита: в e6987459 проверка групп шла раньше проверки is_admin, из-за
чего администратор без заполненного user.groups терял write-доступ.
Финальная версия auth.py проверяет is_admin до групп — это видно в
материале ревью, поэтому не находка, а зафиксированный факт истории.
Диффу не принадлежит ни один файл src/** — карточка (TS/Lit) не меняется,
config/get.can_write и без правки читал may_write(), так что фронтенд
автоматически наследует новую границу без отдельного изменения (подтверждено
чтением websocket_api.py:1472, не тронуто диффом).
Как проверялось
| Гейт | Статус | Источник |
|---|---|---|
npx tsc --noEmit |
не гонял | Validate green на материале покрывает (job «Фронтенд»); диффа по src/** нет |
npm test |
не гонял | то же; npm run inventory-затрагивающих изменений нет |
npm run build + сверка 3 копий бандла |
не гонял | Validate green («Фронтенд: … синхрон бандла») |
node scripts/check-docs.mjs |
не гонял | входит в job «Предполёт»; diff трогает docs/**, job green. Известный существующий stale screenshot fingerprint — предсуществующий, в --screenshots=warn-режиме (обычный push) не блокирует; см. «чего не проверял» |
python -m ruff check custom_components/houseplan |
не гонял отдельно | входит в job «Бэкенд», green |
python -m mypy (strict allowlist) |
не гонял отдельно | входит в job «Бэкенд», green |
python -m pytest tests_backend -q (реальный HA) |
не гонял — в этой среде homeassistant не установлен (ModuleNotFoundError), harness недоступен, test_ha_*.py не собрался бы |
job «Бэкенд: pytest в Home Assistant» на материале — green (canonical по AGENTS.md) |
Мутанты по диффу (6 шардов, включая 3 новых acl-*) |
не гонял — CI это уже сделал | все 6 шардов «Мутанты по диффу» на материале — green |
node scripts/smoke-select.mjs --base d5df838976f6be55d30048510773b6079dce50c1 --head HEAD |
прогнал сам | «Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются… Тронуто файлов: 13.» — смоки не нужны, визуальный контракт не меняется (подтверждает и ТЗ: «новые скриншоты и golden-baselines не требуются») |
golden:verify |
не гонял | нет видимого изменения рендера — не применимо |
| Инварианты геометрии | не гонял | правка не касается геометрии плана |
| Performance-профили | не гонял | в AC не названы |
Дешёвые гейты (typecheck/test/build) и HA-harness backend, а также diff-мутанты
на этом SHA уже подтверждены зелёным Validate (issue #343); я прочитал JSON
списка джобов запуска (gh run view 36125882168 --json jobs) и убедился, что
именно нужные джобы («Бэкенд: pytest в Home Assistant», все 6 шардов «Мутанты
по диффу», «Предполёт») зелёные, а не просто что весь run «success» — smoke/
golden/performance ожидаемо skipped (это не PR, не release-кандидат, диффа
по src/** нет — см. AGENTS.md «Heavy CI gates»).
Помимо CI, я прочитал целиком: auth.py (финальная версия), дифф
websocket_api.py (guard plans/list, _public_trails, ws_trail_get),
весь дифф трёх тестовых файлов и scripts/mutation-registry.mjs, дифф всех
пяти документов.
AC → доказательство
| AC | Доказано | Чем | Чем краснеет |
|---|---|---|---|
AC1 (may_write матрица + fail-closed) |
тестом | test_may_write_honours_explicit_admin_only_false (auth.py:17-55): admin/household/read_only/mixed/groups=[]/нет атрибута groups/группа без id — 7 веток |
мутант acl-readonly-group-becomes-writer (заменяет финальный return на True) — CI job «Мутанты по диффу» green, т.е. свидетель поймал мутанта |
AC2 (6 команд × роли, can_write==may_write) |
тестом | test_issue_626_authenticated_read_acl_matrix_and_trail_projection: admin/household/read_only × config/get.can_write, layout/get, trail/get, plans/list, assets/list; virtual_light/toggle не различает роли по контракту — не входит в матрицу теста намеренно (роли не различают результат), регресс подтверждён отдельно (AC7) |
не требуется — не защитный AC для различающих ролей за пределами уже перечисленных |
AC3 (plans/list unauthorized, без сканирования) |
тестом | test_issue_626_plans_list_refuses_viewer_before_scanning: monkeypatch на hass.async_add_executor_job — исполнитель обязан не вызваться для read-only; guard стоит до _runtime()/чтения каталога (websocket_api.py:1059-1061, до строки 1062 _runtime(...)) |
мутант acl-plan-catalog-open-to-viewers (убирает guard) — пойман, CI green |
AC4 (trail/get без source, storage не мутируется) |
тестом | тот же тест: вложенный route.source удалён, route.id сохранён, recorder.book.data == stored_trails после запроса (проверка отсутствия мутации через copy.deepcopy до и сравнение после) |
мутант acl-trail-view-exposes-source-entity (инвертирует фильтр key != "source") — пойман, CI green |
AC5 (WS config/set и HTTP upload отклоняют read-only при admin_only=false, пропускают household) |
тестом (частично, см. находку) | WS: test_issue_626_authenticated_read_acl_matrix_and_trail_projection (household пишет rev 0→1, read_only получает unauthorized на rev 1). HTTP: test_issue_626_plan_upload_refuses_read_only_but_allows_household |
нет отдельного мутанта на http_api.py, т.к. сам http_api.py диффом не тронут — гейт полагается на существующий вызов may_write() (http_api.py:460) и на unit-покрытие may_write |
| AC6 (документация непротиворечива) | чтением | USER-GUIDE.ru.md/USER-GUIDE.md §20 новая таблица ролей, ARCHITECTURE.md абзац + правка строки trail/get/plans/list в таблице команд, SCOPE.md абзац — формулировки согласуются друг с другом и с контрактом ТЗ построчно |
node scripts/check-docs.mjs — покрыт зелёным job «Предполёт» на материале |
AC7 (регресс: virtual_light/toggle, layout/get, assets/list) |
тестом (существующим, не тронутым диффом) | test_read_only_authenticated_user_can_toggle (test_ha_virtual_lights.py:98-110, не изменён) подтверждает toggle остаётся доступен read-only; layout/get/assets/list проверены внутри нового ACL-матрица теста (AC2) |
покрывается тем же прогоном backend job — весь набор green, значит регресса нет |
Находки
Medium (в скоупе задачи, чинится в ней же)
Потеряна прямая регрессионная проверка HTTP-загрузки при admin_only=true
(значение по умолчанию) для обычного non-admin.
- Файл:
tests_backend/test_ha_upload.py:301-316 - Было:
test_issue_617_plan_upload_refuses_non_admin— вызывала_setup(hass)(без опций ⇒admin_only=trueпо умолчанию) иhass_read_only_access_tokenкак представитель «любой non-admin», проверяла реальный HTTP round-tripPOST /api/houseplan/plans/upload→ 403unauthorized. - Стало: тест переименован в
test_issue_626_plan_upload_refuses_read_only_but_allows_householdи его_setupсменился наoptions={CONF_ADMIN_ONLY: False}— весь тест теперь проверяет только веткуadmin_only=false. План автотестов в самом ТЗ (issue #626, «План автотестов» п.4) допускал оба варианта: «добавить HTTP upload regression… либо расширить существующий тест общей writer-policy» — расширить, не заменить. Просмотрел весь файлtests_backend/test_ha_upload.py(список тестов приведён ниже) — других проверок «admin_only=true(по умолчанию) + non-admin + HTTP upload» не осталось. - Почему это находка, а не педантизм:
http_api.pyэтим диффом не тронут (guardif not may_write(...)наhttp_api.py:460— код 2026-07-27, предшествует #626), но именно поэтому для него нет специального мутанта вscripts/mutation-registry.mjs(проверил — ни одна запись не целится в guard плана-аплоада) и не было раньше. Единственной сеткой, которая ловила регресс конкретно на HTTP-пути приadmin_only=true, был сам этот тест — а он теперь проверяет другую ветку. Останься logикаmay_write()приadmin_only=trueв порядке (она и в порядке — см. AC1), а вот случайный будущий разрыв именно HTTP-проводки (например, вhttp_api.py:460кто-то уберётif, оставив только веткуadmin_onlyвнутри другого места) для default-конфигурации никто не покраснеет: юнит-тестыmay_writeне ходят черезhttp_api.py, а единственный HTTP-round-trip тест для не-писателя теперь заточен подadmin_only=false. - Чем чинится: вернуть в
test_ha_upload.pyвторую проверку — non-admin (обычная группа или read-only, не важно какая приadmin_only=true— обе сейчас writer=false) получает 403 при настройках по умолчанию, ИЛИ добавить отдельный маленький тест на этот случай. - Область: строго внутри изменённого этим диффом файла — правка входит в тот же issue, отдельный issue не заводится.
Других High/Medium не найдено.
Low (снято ревьюером без возврата автору)
custom_components/houseplan/auth.py:35-37— комментарий «HA normally exposes admin groups too…» немного избыточен рядом с однозначным кодом (if is_admin: return True), но не вводит в заблуждение и не противоречит докстрингу модуля. Не влияет ни на один AC — оставляю как есть.
Что проверено и корректно
may_write()(auth.py:17-55): порядок проверокadmin_only→is_admin→ группы; fail-closed наentry is None, наgroupsне-список/ пустой список, на элемент группы без строковогоid. Смешанный набор групп (GROUP_ID_USER+GROUP_ID_READ_ONLY) корректно трактуется как read-only — соответствует продуктовому решению 1 из issue.ws_plans_list(websocket_api.py:1052-1062): guard стоит первой строкой функции, до любого обращения к_runtime/файловой системе — доказано мутацией и monkeypatch-тестом, а не только чтением._public_trails/ws_trail_get(websocket_api.py:2392-2416): чистая рекурсивная проекция, возвращает новую структуру, не мутируетrec.book.data— подтверждено сравнениемrecorder.book.data == stored_trailsпосле запроса в тесте, а не только «выглядит immutable».config/get.can_write(websocket_api.py:1472) не изменён диффом и уже вызывалmay_write()— значит новая граница автоматически видна в UI без отдельной правки фронтенда; UI не имеет собственной копии решения.- Тест
test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off(test_ha_websocket.py:3087+) — до диффа использовалhass_read_only_access_tokenкак «типичный non-admin writer», что было корректно только при старом (ошибочном) поведении. Автор поменял его на_access_token_for_group(hass, GROUP_ID_USER)— необходимое следствие исправления, не потеря покрытия: при старой семантике тест проверял то же самое (не write-only-для-read-only), просто использовал read-only как suррогат обычного пользователя; теперь суррогат заменён на настоящего. - Документация: таблица ролей в
USER-GUIDE.ru.md/USER-GUIDE.md§20, абзац вARCHITECTURE.md(Integration WS API) и абзац вSCOPE.mdговорят одно и то же тремя разными словами без противоречий; строкаplans/list/trail/getв таблице командARCHITECTURE.mdобновлена синхронно с кодом. - Оба changelog (
docs/CHANGELOG.md,docs/CHANGELOG.ru.md) правлены в тех же user-visible коммитах, что и код (в552504b8уточнена одна и та же запись, а не заведена вторая — нет дублирующегося числа/факта с разными источниками). - Не-скоуп соблюдён:
virtual_light/toggle,assets/list-контракт, entity-level фильтрацияconfig/get/layout/get, ACL радаров — ни один файл вне заявленных «Затрагиваемые файлы и модули» не тронут (проверено поgit diff --statцеликом). - SHA
01e51817b615295c3546329cf8434deeef83f02a, названный автором в промежуточном комментарии «готово к ревью», в материале не резолвится (git cat-file -t— «could not get object info»). Это не находка: автор сам обнаружил и исправил дефект после этого коммита следующим пушем, итоговый SHA материала (552504b8) — тот самый, что зафиксирован в шапке задачи ревью, и именно он проверен Validate. Норма по REVIEWER.md («SHA не резолвится — не находка, если не мёртв уже на момент публикации отчёта»): здесь отчёта на том SHA не публиковалось.
Чего не проверял
- Не выполнял
tsc/npm test/npm run build/mypy/ruff/pytest tests_backendвручную — только чтением и по зелёному Validate на точном SHA материала (job-level, не «весь run green» одной галочкой). - Не устанавливал
homeassistantв этой среде — physически невозможно верифицировать 115/4 «passed», заявленные автором локально в WSL, независимо от CI; полагаюсь на canonical Linux CI backend job (по AGENTS.md «HA-харнесс канонически подтверждает Linux CI»), который зелёный на этом самом SHA. - Не прогонял browser-smokes и golden —
smoke-select.mjsсам подтвердил, что для этого диффа (src/**не тронут) выбирать нечего; данные пусты, а не проигнорированы. - Не проверял поведение при реальном Home Assistant инстансе с настоящей
группой
system-read-onlyза пределами того, что покрывает backend-job CI (в частности — поведение при будущих версиях HA, где может измениться составGROUP_ID_READ_ONLY); это риск, который ТЗ явно принимает («используется стабильный ID системной группы read-only»). - Не проверял производительность — в AC не заявлена, диффа по горячим путям рендера нет.
- Строгий (
--screenshots=strict) режимcheck-docs.mjsне гонял — по AGENTS.md это гейт релиз-кандидата, а не обычного push/ревью; автор в комментарии честно указал единственный pre-existing stale fingerprint, не относящийся к этому диффу.
Вердикт
Один Medium в скоупе задачи (потеря регрессионного HTTP-теста для
admin_only=true non-admin, деталь выше) без единого High. Все семь AC
подтверждены — четыре защитных (AC1, AC3, AC4, AC5) имеют мутационное
подтверждение «чем краснеет» для WS-пути; для HTTP-пути AC5 не имеет
собственного мутанта именно из-за находки выше. Изменение решает заявленный
сценарий и не ухудшает ни один из уже закрытых core user jobs (docs/SCOPE.md
J2/J6: read-only остаётся полноценным зрителем, администратор не теряет
доступ). Возвращаю автору на исправление находки; повторный цикл — по
дельте.
Материал раунда
- Ветка:
issue/626-acl-policy, коммит552504b834c7— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
a24a3a11afe04ca54d4e52ed670a3ee038d0a785git log --all --format='%H %T' | grep a24a3a11afe0 - Тело issue:
83f287ba63d6a04d1ffa6f1205382587c2342cbc04bfaebcde04219d841fa564 - Вердикт конвейера:
yellow· High 0