diff --git a/custom_components/houseplan/auth.py b/custom_components/houseplan/auth.py index 6d7ad45a..d78c96e6 100644 --- a/custom_components/houseplan/auth.py +++ b/custom_components/houseplan/auth.py @@ -7,6 +7,7 @@ one behaviour. """ from __future__ import annotations +from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.core import HomeAssistant from .const import CONF_ADMIN_ONLY @@ -28,4 +29,21 @@ def may_write(hass: HomeAssistant, user) -> bool: # UI has always been admin-gated, and an unset option must not open every # write WS/HTTP path to every authenticated household user. admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True)) - return is_admin if admin_only else True + if admin_only: + return is_admin + + # Turning off ``admin_only`` grants editing to ordinary household users, + # not to HA's explicitly read-only role (#626). ``groups`` is part of the + # supported HA User model; if a non-admin connection cannot provide a + # complete group list, the safe interpretation is that write permission + # has not been established. + groups = getattr(user, "groups", None) + if not isinstance(groups, list) or not groups: + return False + group_ids: list[str] = [] + for group in groups: + group_id = getattr(group, "id", None) + if not isinstance(group_id, str): + return False + group_ids.append(group_id) + return GROUP_ID_READ_ONLY not in group_ids diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index 24fed588..ef011301 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -1056,6 +1056,9 @@ async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> works as a policy if the user can see them: detaching a plan keeps the image, and this is how it gets picked up again — or deleted on purpose. """ + if not _check_write(hass, connection): + connection.send_error(msg["id"], "unauthorized", "Only writers may list plans") + return rt = _runtime(hass, connection, msg["id"]) if rt is None: return @@ -2386,12 +2389,27 @@ async def _purge_trail_recorder(hass: HomeAssistant, config: dict[str, Any]) -> return await rec.async_purge_orphans(config) if rec else 0 +def _public_trails(value: Any) -> Any: + """Copy the trail book for View without exposing source entity ids (#626).""" + if isinstance(value, dict): + return { + key: _public_trails(item) + for key, item in value.items() + if key != "source" + } + if isinstance(value, list): + return [_public_trails(item) for item in value] + return value + + @websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"}) @websocket_api.async_response async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None: - """Current + previous cleanup runs per marker, raw robot coordinates.""" + """Current + previous runs and raw coordinates, without source entity ids.""" rec = hass.data.get(DOMAIN, {}).get("trail_recorder") - connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}}) + connection.send_result( + msg["id"], {"trails": _public_trails(rec.book.data) if rec else {}} + ) @websocket_api.websocket_command( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d023c4d9..a9a932ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1220,6 +1220,17 @@ transmit light is the separate `zero_wall_style` policy. ## Integration WS API +`may_write` is the single writer policy for WebSocket and HTTP: administrators +always write; with `admin_only=true` nobody else writes; with +`admin_only=false` ordinary non-admin groups write while +`system-read-only` remains denied. Missing or incomplete user/group data fails +closed. Read ACL is intentionally different: authenticated View receives the +complete config and layout without per-entity projection. The maintenance +catalogs `plans/list` and `assets/list` are writer-only; `trail/get` keeps the +coordinates needed by View but projects every `source` entity ID out of the +response without mutating the recorder store. `virtual_light/toggle` remains an +authenticated View action, not a writer operation. + | Command | Parameters | Response | |---|---|---| | `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` | @@ -1227,13 +1238,13 @@ transmit light is the separate `zero_wall_style` policy. | `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` | | `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}, decor_assets_api?}` (runtime capabilities are optional for rolling compatibility) | | `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` | -| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords | +| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs and raw robot coords, with internal `source` entity IDs removed | | `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion | | `houseplan/config/set` | `config`, `expected_rev` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` | | `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup | | `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit | | `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `..`, deletes nothing | -| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) | +| `houseplan/plans/list` | — | writer-only `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) | | `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` | | `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` | | `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7f5f04f1..104242b2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Home Assistant's `system-read-only` users now remain read-only even when + House Plan editing is opened to ordinary household members. They keep the + complete View experience, including vacuum paths, but cannot edit, upload or + delete data; the maintenance list of stored plan files is writer-only and + vacuum responses no longer expose the internal map-source entity ID + ([#626](https://github.com/Matysh/houseplan-card/issues/626)). - The main toolbar no longer shows the device count (“37 dev.”), and it stops jumping sideways when you open, close or switch editors: the editor’s close **×** now has its own fixed place right after the mode buttons, kept empty diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 9ecf2aa2..c9b4060a 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- Пользователи группы Home Assistant `system-read-only` теперь остаются только + зрителями, даже если редактирование House Plan разрешено обычным домочадцам. + Полноценный просмотр, включая маршруты пылесоса, сохраняется, но редактировать, + загружать и удалять данные нельзя; служебный список файлов планов доступен + только редакторам, а ответ с маршрутом больше не раскрывает внутренний entity + ID источника карты + ([#626](https://github.com/Matysh/houseplan-card/issues/626)). - Основная панель больше не показывает счётчик устройств («37 устр.») и не дёргается по горизонтали при входе в редактор, выходе из него и переключении редакторов: крестик **×** закрытия редактора теперь стоит на diff --git a/docs/SCOPE.md b/docs/SCOPE.md index a8334aa5..48a648c7 100755 --- a/docs/SCOPE.md +++ b/docs/SCOPE.md @@ -30,8 +30,13 @@ obvious" is somebody else's job. | **Guests / kiosk** | View-only glance at the home | Wall tablet | Design consequence: **View mode is the product** for two of the three personas. -Editors are admin-only tools and must never leak interactions into View -(established by UX-MODES; lock guard, inert openings, no drag in View). +Editors are writer-only tools and must never leak interactions into View +(established by UX-MODES; lock guard, inert openings, no drag in View). By +default only administrators are writers; an installation may explicitly let +ordinary household users edit, but Home Assistant's `system-read-only` group +never becomes a writer. Authenticated View deliberately shares the complete +spatial plan and represented-device configuration with household members rather +than pretending to be an entity-by-entity privacy boundary. The desktop browser with mouse/keyboard is the reference and recommended editing environment. Editor parity on touch is outside the product guarantee; deliberate degradation is allowed under `TOUCH-SUPPORT.md`. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index ce914dc5..972db6c5 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -154,8 +154,10 @@ update: it shows a panel asking to reload the page. Every signed-in user can view the plan. Home Assistant permissions still govern device service calls. With the default integration option, only administrators -can edit configuration or upload files. Plan optimization and its undo always -require an administrator. +can edit configuration or upload files. If that option is disabled, ordinary +household users may edit, but members of Home Assistant's `system-read-only` +group never become writers. Plan optimization and its undo always require an +administrator. The **House Plan** sidebar item is visible to every signed-in user. A user who cannot edit gets the complete View experience without editor controls. On an @@ -1289,6 +1291,24 @@ the recovery record remains for another attempt or a Home Assistant restart. ## 20. Storage, multiple cards and backups +### Who can see and change shared data + +| Home Assistant role | Visible in House Plan | May change House Plan | +|---|---|---| +| Administrator | The complete plan, represented devices and the maintenance catalog of plan files | Yes | +| Ordinary user | The complete plan and every device represented on it | Only when “administrators only” editing is disabled for the integration | +| `system-read-only` user | The complete plan and every device represented on it | Never | + +House Plan is a shared spatial map of the home, not a separate per-entity +privacy boundary. The plan configuration is therefore not filtered through the +viewer's entity permissions: a household member who can open View sees its rooms +and represented devices. Normal Home Assistant permissions still govern actions +against real devices. + +The maintenance catalog of stored plan files (names, sizes and usage) is +writer-only. Vacuum coordinates remain available because View renders the path, +but the map source's internal entity ID is removed from the response. + ### Portable JSON backup **Global settings → Backup and transfer** exports either the complete House diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index f5a41424..8f5e8ae7 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -159,7 +159,7 @@ storage-панель в устаревший YAML только ради House Pl | Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов | |---|---:|---:|---:| | Только администраторы — включено (по умолчанию) | Все вошедшие пользователи | По обычным правам HA | Только администраторы | -| Только администраторы — выключено | Все вошедшие пользователи | По обычным правам HA | Все вошедшие пользователи | +| Только администраторы — выключено | Все вошедшие пользователи | По обычным правам HA | Все, кроме пользователей группы HA `system-read-only` | Оптимизация планов и отмена оптимизации всегда требуют администратора, независимо от этой опции. @@ -2103,6 +2103,25 @@ House Plan не позволит следующей правке перезап ## 20. Хранение, совместная работа и резервные копии +### Кто видит и изменяет общие данные + +| Роль Home Assistant | Что видно в House Plan | Можно изменять House Plan | +|---|---|---| +| Администратор | Весь план, устройства и служебный каталог файлов планов | Да | +| Обычный пользователь | Весь план и все представленные на нём устройства | Только если в интеграции выключено «редактировать план только администраторам» | +| Пользователь группы `system-read-only` | Весь план и все представленные на нём устройства | Нет при любом значении настройки | + +House Plan — общая пространственная карта дома, а не отдельная граница +приватности для каждой сущности. Поэтому конфигурация плана не фильтруется по +entity-разрешениям конкретного пользователя: домочадец, которому доступен View, +видит комнаты и представленные на плане устройства. Обычные права Home +Assistant по-прежнему применяются к действиям с реальными устройствами. + +Служебный список файлов планов (имена, размеры и использование) доступен только +тем, кто может редактировать. Маршруты пылесоса нужны режиму View, поэтому их +координаты доступны зрителю, но внутренний entity ID источника карты в ответ не +передаётся. + ### Переносимая JSON-копия House Plan Откройте **Общие настройки → Резервная копия и перенос**. Экспорт предлагает diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 1305ad4b..c4dbe6cc 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -72,6 +72,48 @@ function relocateEditorPatch(patch, cardSource, editorSource) { // `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда // попало», проверяет не то, что объявлен проверять. Это контролирует --check. const MUTANT_DEFINITIONS = [ + { + id: 'acl-readonly-group-becomes-writer', + guard: 'node scripts/backend-test-guard.mjs ' + + 'may_write_honours_explicit_admin_only_false ' + + 'tests_backend/test_ha_websocket.py', + because: '#626 AC1/AC5: disabling admin_only admits household editors, but HA system-read-only ' + + 'must remain unable to mutate config or upload content.', + patches: [{ + file: 'custom_components/houseplan/auth.py', + find: ' return GROUP_ID_READ_ONLY not in group_ids\n', + replace: ' return True # mutant: read-only group inherits household writer access\n', + }], + }, + { + id: 'acl-plan-catalog-open-to-viewers', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_626_plans_list_refuses_viewer_before_scanning ' + + 'tests_backend/test_ha_websocket.py', + because: '#626 AC2/AC3: the stored plan filename and usage catalog is maintenance data for ' + + 'writers, not part of authenticated View.', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' if not _check_write(hass, connection):\n' + + ' connection.send_error(msg["id"], "unauthorized", "Only writers may list plans")\n' + + ' return\n' + + ' rt = _runtime(hass, connection, msg["id"])\n', + replace: ' rt = _runtime(hass, connection, msg["id"]) # mutant: viewers list plan files\n', + }], + }, + { + id: 'acl-trail-view-exposes-source-entity', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_626_authenticated_read_acl_matrix_and_trail_projection ' + + 'tests_backend/test_ha_websocket.py', + because: '#626 AC4: View needs robot coordinates, not private source entity ids embedded at ' + + 'arbitrary levels of current and previous route records.', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' if key != "source"\n', + replace: ' if True # mutant: expose source entity ids\n', + }], + }, { id: 'view-conflict-requires-editor-runtime', guard: 'node --test test/config-write-conflict.test.mjs', diff --git a/tests_backend/test_ha_upload.py b/tests_backend/test_ha_upload.py index 98930647..03f0150d 100644 --- a/tests_backend/test_ha_upload.py +++ b/tests_backend/test_ha_upload.py @@ -8,20 +8,31 @@ def _enable_custom_integrations(enable_custom_integrations): yield from aiohttp import FormData +from homeassistant.auth.const import GROUP_ID_USER from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.typing import ClientSessionGenerator -from custom_components.houseplan.const import DOMAIN +from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN -async def _setup(hass: HomeAssistant) -> None: - entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={}) +async def _setup(hass: HomeAssistant, *, options: dict | None = None) -> None: + entry = MockConfigEntry( + domain=DOMAIN, title="House Plan", data={}, options=options or {} + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() +async def _household_access_token(hass: HomeAssistant) -> str: + user = await hass.auth.async_create_user( + "House Plan household", group_ids=[GROUP_ID_USER] + ) + refresh_token = await hass.auth.async_create_refresh_token(user) + return hass.auth.async_create_access_token(refresh_token) + + async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None: await _setup(hass) client = await hass_client() @@ -285,11 +296,11 @@ async def test_issue_617_plan_upload_limit_is_inclusive_and_refusal_leaves_nothi assert after == before, "a refused plan leaves neither a file nor a temporary behind" -async def test_issue_617_plan_upload_refuses_non_admin( +async def test_issue_626_plan_upload_refuses_read_only_but_allows_household( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_read_only_access_token: str, ) -> None: - """#617 AC4: the same write policy as ws_plan_set.""" - await _setup(hass) + """#626 AC5: HTTP upload follows the same group-aware writer policy as WS.""" + await _setup(hass, options={CONF_ADMIN_ONLY: False}) client = await hass_client(hass_read_only_access_token) before = await hass.async_add_executor_job(_plans_listing, hass) resp = await client.post("/api/houseplan/plans/upload", data=_plan_form(b"PLAN")) @@ -297,6 +308,12 @@ async def test_issue_617_plan_upload_refuses_non_admin( assert (await resp.json())["error"] == "unauthorized" assert await hass.async_add_executor_job(_plans_listing, hass) == before + household = await hass_client(await _household_access_token(hass)) + allowed = await household.post( + "/api/houseplan/plans/upload", data=_plan_form(b"PLAN", "household") + ) + assert allowed.status == 200, await allowed.text() + async def test_issue_617_plan_upload_validates_fields_like_ws_plan_set( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 464aa268..9a8c6ffa 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -6,6 +6,7 @@ import logging import threading import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -15,6 +16,7 @@ def _enable_custom_integrations(enable_custom_integrations): """Allow loading custom_components in the test hass.""" yield +from homeassistant.auth.const import GROUP_ID_READ_ONLY, GROUP_ID_USER from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.typing import WebSocketGenerator @@ -31,14 +33,27 @@ from custom_components.houseplan.websocket_api import ( ) -async def _setup(hass: HomeAssistant) -> MockConfigEntry: - entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={}) +async def _setup( + hass: HomeAssistant, *, options: dict | None = None +) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, title="House Plan", data={}, options=options or {} + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry +async def _access_token_for_group(hass: HomeAssistant, group_id: str) -> str: + """Create an authenticated non-owner client for one real HA system group.""" + user = await hass.auth.async_create_user( + f"House Plan {group_id}", group_ids=[group_id] + ) + refresh_token = await hass.auth.async_create_refresh_token(user) + return hass.auth.async_create_access_token(refresh_token) + + async def test_config_get_advertises_radar_only_while_coordinator_is_ready( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -1950,7 +1965,7 @@ async def test_may_write_defaults_admin_only_when_option_missing(hass): async def test_may_write_honours_explicit_admin_only_false(hass): - """Household users may write only when the option is explicitly off.""" + """#626: admin_only off admits household users, never HA read-only users.""" from custom_components.houseplan.auth import may_write entry = MockConfigEntry( @@ -1960,10 +1975,125 @@ async def test_may_write_honours_explicit_admin_only_false(hass): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - class _User: - is_admin = False + household = SimpleNamespace( + is_admin=False, groups=[SimpleNamespace(id=GROUP_ID_USER)] + ) + read_only = SimpleNamespace( + is_admin=False, groups=[SimpleNamespace(id=GROUP_ID_READ_ONLY)] + ) + mixed = SimpleNamespace( + is_admin=False, + groups=[ + SimpleNamespace(id=GROUP_ID_USER), + SimpleNamespace(id=GROUP_ID_READ_ONLY), + ], + ) + admin = SimpleNamespace(is_admin=True) - assert may_write(hass, _User()) is True + assert may_write(hass, admin) is True + assert may_write(hass, household) is True + assert may_write(hass, read_only) is False + assert may_write(hass, mixed) is False + assert may_write(hass, SimpleNamespace(is_admin=False, groups=[])) is False + assert may_write(hass, SimpleNamespace(is_admin=False)) is False + assert may_write( + hass, SimpleNamespace(is_admin=False, groups=[SimpleNamespace()]) + ) is False + + +async def test_issue_626_authenticated_read_acl_matrix_and_trail_projection( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """#626 AC2/AC4/AC5/AC7: one observable matrix pins every read role.""" + await _setup(hass, options={CONF_ADMIN_ONLY: False}) + admin = await hass_ws_client(hass) + household = await hass_ws_client( + hass, access_token=await _access_token_for_group(hass, GROUP_ID_USER) + ) + read_only = await hass_ws_client( + hass, access_token=hass_read_only_access_token + ) + + recorder = hass.data[DOMAIN]["trail_recorder"] + stored_trails = { + "robot": { + "current": { + "source": "camera.private_map", + "route": {"source": "camera.private_route", "id": "ground"}, + "points": [[10.0, 20.0]], + }, + "previous": { + "source": "sensor.private_map", + "points": [[30.0, 40.0]], + }, + } + } + recorder.book.data = copy.deepcopy(stored_trails) + + for client, can_write in ( + (admin, True), + (household, True), + (read_only, False), + ): + await client.send_json_auto_id({"type": "houseplan/config/get"}) + config = await client.receive_json() + assert config["success"] and config["result"]["can_write"] is can_write + + await client.send_json_auto_id({"type": "houseplan/layout/get"}) + assert (await client.receive_json())["success"] + + await client.send_json_auto_id({"type": "houseplan/trail/get"}) + trails_response = await client.receive_json() + assert trails_response["success"] + public_trails = trails_response["result"]["trails"] + assert public_trails["robot"]["current"]["points"] == [[10.0, 20.0]] + assert "source" not in json.dumps(public_trails) + + for command in ("houseplan/plans/list", "houseplan/assets/list"): + await client.send_json_auto_id({"type": command}) + response = await client.receive_json() + if can_write: + assert response["success"], (command, response) + else: + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + assert recorder.book.data == stored_trails, "View projection must not mutate storage" + + candidate = {"spaces": [], "markers": [], "settings": {}} + await household.send_json_auto_id({ + "type": "houseplan/config/set", "config": candidate, "expected_rev": 0, + }) + assert (await household.receive_json())["success"] + await read_only.send_json_auto_id({ + "type": "houseplan/config/set", "config": candidate, "expected_rev": 1, + }) + refused = await read_only.receive_json() + assert not refused["success"] and refused["error"]["code"] == "unauthorized" + + +async def test_issue_626_plans_list_refuses_viewer_before_scanning( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#626 AC3: refusal happens before filesystem work or catalog disclosure.""" + await _setup(hass, options={CONF_ADMIN_ONLY: False}) + read_only = await hass_ws_client( + hass, access_token=hass_read_only_access_token + ) + + def unexpected_executor_call(*_args, **_kwargs): + raise AssertionError("plans/list touched the filesystem for a viewer") + + monkeypatch.setattr(hass, "async_add_executor_job", unexpected_executor_call) + await read_only.send_json_auto_id({"type": "houseplan/plans/list"}) + response = await read_only.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unauthorized" async def test_config_get_reports_can_write(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: @@ -2955,7 +3085,6 @@ async def test_decor_asset_resolve_readonly_is_limited_to_referenced_ids( async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - hass_read_only_access_token: str, ) -> None: """#432 AC2: resolve follows may_write instead of hard-coding admin.""" import hashlib @@ -2979,7 +3108,9 @@ async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off( "created_at": "2026-01-01T00:00:00Z", }), encoding="utf-8") - client = await hass_ws_client(hass, access_token=hass_read_only_access_token) + client = await hass_ws_client( + hass, access_token=await _access_token_for_group(hass, GROUP_ID_USER) + ) await client.send_json_auto_id({ "type": "houseplan/assets/resolve", "asset_ids": [aid], })