From 53b20e8c45a072e72bbbbcac3be68cacce039a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:30:39 +0300 Subject: [PATCH] =?UTF-8?q?test(backend):=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20marker-id=20write=20boundaries=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #625 User-Visible: no --- scripts/mutation-registry.mjs | 39 +++++++++++ tests_backend/test_ha_websocket.py | 107 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index fc9303a9..6c0ced74 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -10115,6 +10115,45 @@ const MUTANT_DEFINITIONS = [ replace: ' # mutant: staged bytes are reserved a second time\n', }], }, + { + id: 'config-set-skips-active-marker-id-invariant', + guard: 'node scripts/backend-test-guard.mjs ' + + 'config_set_rejects_duplicate_active_marker_ids ' + + 'tests_backend/test_ha_websocket.py', + because: 'config/set is the ordinary and most frequent configuration writer; without its ' + + 'write-boundary check a crafted client can persist two active markers under one id (#625 AC3)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' validate_active_marker_ids(msg["config"], data.get("config"))\n', + replace: ' # mutant: config/set skips the active marker id invariant\n', + }], + }, + { + id: 'space-delete-skips-active-marker-id-invariant', + guard: 'node scripts/backend-test-guard.mjs ' + + 'space_delete_rejects_changed_legacy_duplicate_marker_ids ' + + 'tests_backend/test_ha_websocket.py', + because: 'space/delete rewrites marker placement fields; a changed legacy duplicate group ' + + 'must not be committed merely because it existed before this structural write (#625 AC3)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' validate_active_marker_ids(target_config, current_config)\n', + replace: ' # mutant: space/delete skips the active marker id invariant\n', + }], + }, + { + id: 'plan-optimize-skips-active-marker-id-invariant', + guard: 'node scripts/backend-test-guard.mjs ' + + 'plan_optimize_rejects_duplicate_active_marker_ids ' + + 'tests_backend/test_ha_websocket.py', + because: 'Optimize replaces the whole configuration and cannot be a side door around the ' + + 'same active marker id invariant enforced by the ordinary writer (#625 AC3)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' validate_active_marker_ids(msg["config"], config_data.get("config"))\n', + replace: ' # mutant: Optimize skips the active marker id invariant\n', + }], + }, { id: 'support-palette-copies-any-key', guard: 'node scripts/backend-test-guard.mjs ' diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index eebe3d13..464aa268 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -1092,6 +1092,113 @@ async def test_plan_optimize_rejects_new_marker_light_cycle( assert stored["rev"] == 1 and stored["config"] == base +async def test_config_set_rejects_duplicate_active_marker_ids( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """The ordinary config writer must enforce the active marker id invariant.""" + await _setup(hass) + client = await hass_ws_client(hass) + duplicate = { + "spaces": [], "settings": {}, + "markers": [ + {"id": "duplicate", "binding": "virtual", "name": "First"}, + {"id": "duplicate", "binding": "virtual", "name": "Second"}, + ], + } + + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": duplicate, "expected_rev": 0, + }) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "invalid_config", "message": "duplicate active marker id", + } + + await client.send_json_auto_id({"type": "houseplan/config/get"}) + stored = (await client.receive_json())["result"] + assert stored["rev"] == 0 and stored["config"]["markers"] == [] + + +async def test_space_delete_rejects_changed_legacy_duplicate_marker_ids( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """A structural writer cannot retain a duplicate group that it changes.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + legacy = { + "spaces": [_space("only", "room-only")], "settings": {}, + "markers": [ + { + "id": "duplicate", "binding": "virtual", "name": "First", + "space": "only", "room_id": "room-only", + }, + { + "id": "duplicate", "binding": "virtual", "name": "Second", + "space": "only", "room_id": "room-only", + }, + ], + } + before_config = {"config": legacy, "rev": 1} + before_layout = {"layout": {}, "rev": 0} + await runtime.config_store.async_save(before_config) + await runtime.store.async_save(before_layout) + + await client.send_json_auto_id({ + "type": "houseplan/space/delete", "space_id": "only", + "expected_config_rev": 1, "expected_layout_rev": 0, + }) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "invalid_config", "message": "duplicate active marker id", + } + assert await runtime.config_store.async_load() == before_config + assert await runtime.store.async_load() == before_layout + + +async def test_plan_optimize_rejects_duplicate_active_marker_ids( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """Optimize must not bypass the same marker id invariant as config/set.""" + await _setup(hass) + client = await hass_ws_client(hass) + base = {"spaces": [], "markers": [], "settings": {}} + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": base, "expected_rev": 0, + }) + seeded = await client.receive_json() + assert seeded["success"] and seeded["result"]["rev"] == 1 + duplicate = { + **base, + "markers": [ + {"id": "duplicate", "binding": "virtual", "name": "First"}, + {"id": "duplicate", "binding": "virtual", "name": "Second"}, + ], + } + + await client.send_json_auto_id({ + "type": "houseplan/plan/optimize", "config": duplicate, "layout": {}, + "expected_config_rev": 1, "expected_layout_rev": 0, + }) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "invalid_config", "message": "duplicate active marker id", + } + + await client.send_json_auto_id({"type": "houseplan/config/get"}) + stored_config = (await client.receive_json())["result"] + await client.send_json_auto_id({"type": "houseplan/layout/get"}) + stored_layout = (await client.receive_json())["result"] + assert stored_config["rev"] == 1 and stored_config["config"] == base + assert stored_layout["rev"] == 0 and stored_layout["layout"] == {} + + @pytest.mark.parametrize("endpoint", [ "houseplan/config/set", "houseplan/plan/optimize",