From c45dc492705f92241f63b4fab941eb34cc4500b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 11:38:29 -1000 Subject: [PATCH] Escape % and _ in history/logbook entity_globs, and use ? as _ (#72623) Co-authored-by: pyos --- homeassistant/components/recorder/filters.py | 11 +- tests/components/history/test_init.py | 12 +- .../components/logbook/test_websocket_api.py | 207 ++++++++++++++++++ 3 files changed, 223 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0a383d8ef2b..5dd1e4b7884 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -18,8 +18,11 @@ DOMAIN = "history" HISTORY_FILTERS = "history_filters" GLOB_TO_SQL_CHARS = { - 42: "%", # * - 46: "_", # . + ord("*"): "%", + ord("?"): "_", + ord("%"): "\\%", + ord("_"): "\\_", + ord("\\"): "\\\\", } @@ -122,7 +125,9 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" return or_( - cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + cast(column, Text()).like( + encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" + ) for glob_str in glob_strs for column in columns ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a2626ab2004..cbc5e86c37e 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -719,7 +719,7 @@ async def test_fetch_period_api_with_entity_glob_exclude( { "history": { "exclude": { - "entity_globs": ["light.k*"], + "entity_globs": ["light.k*", "binary_sensor.*_?"], "domains": "switch", "entities": "media_player.test", }, @@ -731,6 +731,9 @@ async def test_fetch_period_api_with_entity_glob_exclude( hass.states.async_set("light.match", "on") hass.states.async_set("switch.match", "on") hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.sensor_l", "on") + hass.states.async_set("binary_sensor.sensor_r", "on") + hass.states.async_set("binary_sensor.sensor", "on") await async_wait_recording_done(hass) @@ -740,9 +743,10 @@ async def test_fetch_period_api_with_entity_glob_exclude( ) assert response.status == HTTPStatus.OK response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.cow" - assert response_json[1][0]["entity_id"] == "light.match" + assert len(response_json) == 3 + assert response_json[0][0]["entity_id"] == "binary_sensor.sensor" + assert response_json[1][0]["entity_id"] == "light.cow" + assert response_json[2][0]["entity_id"] == "light.match" async def test_fetch_period_api_with_entity_glob_include_and_exclude( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 02fea4f980f..9d7146ec96c 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, @@ -642,6 +643,212 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( assert sum(hass.bus.async_listeners().values()) == init_count +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_unsubscribe_logbook_stream_included_entities( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with included entities.""" + test_entities = ( + "light.inc", + "switch.any", + "cover.included", + "cover.not_included", + "automation.not_included", + "binary_sensor.is_light", + ) + + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_ENTITIES: ["light.inc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: "*.included", + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + for _ in range(3): + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await async_wait_recording_done(hass) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.included"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.inc"}, + ) + + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": "cover.included", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "switch.match_domain", + "message": "triggered", + "name": "Mock automation switch matching entity", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation switch matching domain", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.inc", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client