diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index f78d89032ab5..52e5ac63ac01 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -178,8 +178,26 @@ def websocket_update_device( # Convert labels to a set msg["labels"] = set(msg["labels"]) + device_id = msg["device_id"] + + # A composite device id has no single underlying device to update; reject it. + if ( + registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None + ): + connection.send_error( + msg_id, websocket_api.ERR_NOT_ALLOWED, "Cannot update a composite device" + ) + return + if ( + device := registry.async_get(device_id, include_composite_devices=False) + ) is None: + connection.send_error(msg_id, websocket_api.ERR_NOT_FOUND, "Device not found") + return + entry: dr.AnyDeviceEntry | None - device = registry.async_get(msg["device_id"], include_composite_devices=False) if isinstance(device, dr.ChildDeviceEntry): entry = registry.async_update_child_device(**msg) else: diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 67acc84ee112..a3a5227bc9f9 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -388,6 +388,82 @@ async def test_update_device_labels( assert getattr(device, key) == value +async def test_update_device_unknown_device( + hass: HomeAssistant, + client: MockHAClientWebSocket, +) -> None: + """Test updating an unknown device returns an error.""" + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": "does_not_exist", + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert msg["error"]["message"] == "Device not found" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_update_device_composite( + hass: HomeAssistant, + client: MockHAClientWebSocket, + hass_storage: dict[str, Any], +) -> None: + """Test updating a pre-migration composite device id is rejected.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + composite_id = "compositea000000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite spanning two config entries; splitting it on load removes + # the composite device, so composite_id no longer refers to a device + _storage_device_v1_12( + composite_id, + [entry_1.entry_id, entry_2.entry_id], + entry_1.entry_id, + "a", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + # pylint: disable-next=home-assistant-tests-registry-fixtures + registry = dr.async_get(hass) + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None + + await client.send_json_auto_id( + { + "type": "config/device_registry/update", + "device_id": composite_id, + "name_by_user": "Test Friendly Name", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_allowed" + assert msg["error"]["message"] == "Cannot update a composite device" + + # The update was not fanned out to the underlying split devices + for split in registry.async_get_devices_for_composite_device_id(composite_id): + assert split.name_by_user is None + + _DEPRECATION_WARNING = ( "The websocket command config/device_registry/remove_config_entry is " "deprecated and will be removed in Home Assistant 2027.9"