diff --git a/surfsense_backend/app/automations/triggers/builtin/event/filter.py b/surfsense_backend/app/automations/triggers/builtin/event/filter.py new file mode 100644 index 000000000..9f13cd51e --- /dev/null +++ b/surfsense_backend/app/automations/triggers/builtin/event/filter.py @@ -0,0 +1,78 @@ +"""Pure JSON filter grammar: ``matches(filter_expr, payload) -> bool``. + +The ``event`` trigger uses it to decide whether an event fires the automation. +""" + +from __future__ import annotations + +import operator +from collections.abc import Callable +from typing import Any + + +class FilterError(ValueError): + """Unknown operator in a filter. Raised (not silently false) so a bad filter + fails at authoring time instead of quietly disabling the trigger.""" + + +# Scalar comparison operators: (actual, operand) -> bool. +_COMPARATORS: dict[str, Callable[[Any, Any], bool]] = { + "$eq": operator.eq, + "$ne": operator.ne, + "$gt": operator.gt, + "$gte": operator.ge, + "$lt": operator.lt, + "$lte": operator.le, + "$in": lambda actual, operand: actual in operand, + "$nin": lambda actual, operand: actual not in operand, +} + +# Sentinel for "the payload has no such field" — distinct from a present None. +_MISSING = object() + + +def matches(filter_expr: dict[str, Any], payload: dict[str, Any]) -> bool: + """Return ``True`` when ``payload`` satisfies every constraint in ``filter_expr``. + + An empty filter expresses "no constraints" and matches every payload. + Sibling keys (fields and logical operators alike) are ANDed together. + """ + for key, value in filter_expr.items(): + if key == "$and": + if not all(matches(sub, payload) for sub in value): + return False + elif key == "$or": + if not any(matches(sub, payload) for sub in value): + return False + elif key == "$not": + if matches(value, payload): + return False + elif key.startswith("$"): + raise FilterError(f"unknown logical operator: {key}") + elif not _match_condition(value, payload.get(key, _MISSING)): + return False + return True + + +def _match_condition(condition: Any, actual: Any) -> bool: + """Match one field's ``actual`` value against its ``condition``. + + A dict condition is an operator object (``{"$gt": 10}``); every operator in + it must hold. Any other value is an implicit equality check. A field absent + from the payload (``actual is _MISSING``) fails every constraint. + """ + if actual is _MISSING: + return False + if isinstance(condition, dict): + return all( + _apply_operator(op, operand, actual) + for op, operand in condition.items() + ) + return actual == condition + + +def _apply_operator(op: str, operand: Any, actual: Any) -> bool: + comparator = _COMPARATORS.get(op) + if comparator is not None: + return comparator(actual, operand) + raise FilterError(f"unknown operator: {op}") diff --git a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py new file mode 100644 index 000000000..9ddc3503a --- /dev/null +++ b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py @@ -0,0 +1,115 @@ +"""Behavior tests for the ``matches`` filter grammar.""" + +from __future__ import annotations + +import pytest + +from app.automations.triggers.builtin.event.filter import FilterError, matches + +pytestmark = pytest.mark.unit + + +def test_empty_filter_matches_any_payload() -> None: + assert matches({}, {"document_id": 42, "document_type": "FILE"}) is True + assert matches({}, {}) is True + + +def test_scalar_value_is_implicit_equality() -> None: + flt = {"document_type": "FILE"} + assert matches(flt, {"document_type": "FILE"}) is True + assert matches(flt, {"document_type": "WEBPAGE"}) is False + + +def test_multiple_fields_are_anded() -> None: + flt = {"document_type": "FILE", "search_space_id": 7} + assert matches(flt, {"document_type": "FILE", "search_space_id": 7}) is True + assert matches(flt, {"document_type": "FILE", "search_space_id": 9}) is False + + +def test_gt_operator_compares_greater_than() -> None: + flt = {"page_count": {"$gt": 10}} + assert matches(flt, {"page_count": 20}) is True + assert matches(flt, {"page_count": 10}) is False + assert matches(flt, {"page_count": 5}) is False + + +def test_remaining_comparison_operators() -> None: + assert matches({"n": {"$gte": 10}}, {"n": 10}) is True + assert matches({"n": {"$gte": 10}}, {"n": 9}) is False + + assert matches({"n": {"$lt": 10}}, {"n": 9}) is True + assert matches({"n": {"$lt": 10}}, {"n": 10}) is False + + assert matches({"n": {"$lte": 10}}, {"n": 10}) is True + assert matches({"n": {"$lte": 10}}, {"n": 11}) is False + + assert matches({"s": {"$eq": "FILE"}}, {"s": "FILE"}) is True + assert matches({"s": {"$eq": "FILE"}}, {"s": "WEB"}) is False + + assert matches({"s": {"$ne": "FILE"}}, {"s": "WEB"}) is True + assert matches({"s": {"$ne": "FILE"}}, {"s": "FILE"}) is False + + +def test_multiple_operators_on_one_field_are_anded() -> None: + flt = {"n": {"$gte": 10, "$lt": 20}} + assert matches(flt, {"n": 15}) is True + assert matches(flt, {"n": 10}) is True + assert matches(flt, {"n": 20}) is False + assert matches(flt, {"n": 5}) is False + + +def test_in_and_nin_membership_operators() -> None: + flt_in = {"document_type": {"$in": ["FILE", "WEBPAGE"]}} + assert matches(flt_in, {"document_type": "FILE"}) is True + assert matches(flt_in, {"document_type": "SLACK"}) is False + + flt_nin = {"document_type": {"$nin": ["FILE", "WEBPAGE"]}} + assert matches(flt_nin, {"document_type": "SLACK"}) is True + assert matches(flt_nin, {"document_type": "FILE"}) is False + + +def test_or_matches_when_any_branch_holds() -> None: + flt = {"$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}]} + assert matches(flt, {"document_type": "WEBPAGE"}) is True + assert matches(flt, {"document_type": "SLACK"}) is False + + +def test_and_matches_when_every_branch_holds() -> None: + flt = {"$and": [{"n": {"$gt": 5}}, {"n": {"$lt": 10}}]} + assert matches(flt, {"n": 7}) is True + assert matches(flt, {"n": 12}) is False + + +def test_not_inverts_its_subexpression() -> None: + flt = {"$not": {"document_type": "FILE"}} + assert matches(flt, {"document_type": "WEBPAGE"}) is True + assert matches(flt, {"document_type": "FILE"}) is False + + +def test_missing_field_never_matches_and_never_raises() -> None: + # Conservative: an absent field fails the constraint, and comparisons must + # not raise on the missing value — including $ne (absence isn't "not equal"). + assert matches({"document_type": "FILE"}, {}) is False + assert matches({"page_count": {"$gt": 5}}, {}) is False + assert matches({"document_type": {"$in": ["FILE"]}}, {}) is False + assert matches({"document_type": {"$ne": "FILE"}}, {}) is False + + +def test_logical_operators_compose_with_fields() -> None: + flt = { + "search_space_id": 7, + "$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}], + } + assert matches(flt, {"search_space_id": 7, "document_type": "FILE"}) is True + assert matches(flt, {"search_space_id": 9, "document_type": "FILE"}) is False + assert matches(flt, {"search_space_id": 7, "document_type": "SLACK"}) is False + + +def test_unknown_field_operator_raises_filter_error() -> None: + with pytest.raises(FilterError): + matches({"n": {"$regex": "x"}}, {"n": "xyz"}) + + +def test_unknown_logical_operator_raises_filter_error() -> None: + with pytest.raises(FilterError): + matches({"$nor": [{"document_type": "FILE"}]}, {"document_type": "FILE"})