/ tests / test_gaql_validator.py
test_gaql_validator.py
  1  """GAQL validator tests.
  2  
  3  Pure validation functions for GAQL query construction.
  4  No DB/API required — all synchronous, deterministic.
  5  """
  6  
  7  from __future__ import annotations
  8  
  9  import pytest
 10  
 11  from mureo.google_ads._gaql_validator import (
 12      VALID_DATE_RANGE_CONSTANTS,
 13      GAQLValidationError,
 14      build_in_clause,
 15      escape_string_literal,
 16      validate_date,
 17      validate_date_range_constant,
 18      validate_id,
 19      validate_id_list,
 20      validate_period_days,
 21  )
 22  
 23  
 24  @pytest.mark.unit
 25  class TestValidateId:
 26      def test_valid_digits(self) -> None:
 27          assert validate_id("1234567890", "campaign_id") == "1234567890"
 28  
 29      def test_empty_rejected(self) -> None:
 30          with pytest.raises(GAQLValidationError, match="campaign_id"):
 31              validate_id("", "campaign_id")
 32  
 33      def test_injection_attempt_rejected(self) -> None:
 34          with pytest.raises(GAQLValidationError):
 35              validate_id("123 OR 1=1", "campaign_id")
 36  
 37      def test_quote_rejected(self) -> None:
 38          with pytest.raises(GAQLValidationError):
 39              validate_id("123'", "campaign_id")
 40  
 41      def test_dash_rejected(self) -> None:
 42          # customer_id with dashes must be normalized by caller first
 43          with pytest.raises(GAQLValidationError):
 44              validate_id("123-456-7890", "customer_id")
 45  
 46      def test_leading_space_rejected(self) -> None:
 47          with pytest.raises(GAQLValidationError):
 48              validate_id(" 123", "id")
 49  
 50      def test_error_is_valueerror(self) -> None:
 51          with pytest.raises(ValueError):
 52              validate_id("bad", "id")
 53  
 54      def test_length_cap(self) -> None:
 55          # 20 digits is the practical Google Ads ID max — one more is rejected.
 56          assert validate_id("1" * 20, "id") == "1" * 20
 57          with pytest.raises(GAQLValidationError):
 58              validate_id("1" * 21, "id")
 59  
 60  
 61  @pytest.mark.unit
 62  class TestValidateIdList:
 63      def test_valid_list(self) -> None:
 64          assert validate_id_list(["1", "2", "3"], "campaign_id") == ["1", "2", "3"]
 65  
 66      def test_empty_list_rejected(self) -> None:
 67          with pytest.raises(GAQLValidationError, match="empty"):
 68              validate_id_list([], "campaign_id")
 69  
 70      def test_one_bad_id_rejects_all(self) -> None:
 71          with pytest.raises(GAQLValidationError):
 72              validate_id_list(["1", "2 OR 1=1", "3"], "campaign_id")
 73  
 74      def test_preserves_order(self) -> None:
 75          assert validate_id_list(["9", "1", "5"], "id") == ["9", "1", "5"]
 76  
 77  
 78  @pytest.mark.unit
 79  class TestValidateDate:
 80      def test_valid_date(self) -> None:
 81          assert validate_date("2025-04-01", "start_date") == "2025-04-01"
 82  
 83      def test_wrong_format_rejected(self) -> None:
 84          with pytest.raises(GAQLValidationError):
 85              validate_date("2025/04/01", "start_date")
 86  
 87      def test_injection_rejected(self) -> None:
 88          with pytest.raises(GAQLValidationError):
 89              validate_date("2025-04-01' OR '1'='1", "start_date")
 90  
 91  
 92  @pytest.mark.unit
 93  class TestValidateDateRangeConstant:
 94      def test_last_7_days(self) -> None:
 95          assert validate_date_range_constant("LAST_7_DAYS") == "LAST_7_DAYS"
 96  
 97      def test_lowercase_normalized(self) -> None:
 98          assert validate_date_range_constant("last_30_days") == "LAST_30_DAYS"
 99  
100      def test_unknown_constant_rejected(self) -> None:
101          with pytest.raises(GAQLValidationError):
102              validate_date_range_constant("LAST_999_DAYS")
103  
104      def test_injection_rejected(self) -> None:
105          with pytest.raises(GAQLValidationError):
106              validate_date_range_constant("LAST_7_DAYS; DROP TABLE")
107  
108      def test_known_constants_include_common_ones(self) -> None:
109          assert "LAST_7_DAYS" in VALID_DATE_RANGE_CONSTANTS
110          assert "LAST_30_DAYS" in VALID_DATE_RANGE_CONSTANTS
111          assert "THIS_MONTH" in VALID_DATE_RANGE_CONSTANTS
112  
113      def test_all_time_rejected(self) -> None:
114          # ALL_TIME bypasses the period-days guard — callers must use BETWEEN.
115          with pytest.raises(GAQLValidationError):
116              validate_date_range_constant("ALL_TIME")
117  
118  
119  @pytest.mark.unit
120  class TestEscapeStringLiteral:
121      def test_simple_string_unchanged(self) -> None:
122          assert escape_string_literal("hello") == "hello"
123  
124      def test_single_quote_escaped(self) -> None:
125          assert escape_string_literal("O'Brien") == "O\\'Brien"
126  
127      def test_backslash_escaped_first(self) -> None:
128          assert escape_string_literal("a\\b") == "a\\\\b"
129  
130      def test_combined(self) -> None:
131          # Backslash must be escaped before quotes so escape sequences aren't
132          # double-processed
133          assert escape_string_literal("a\\'b") == "a\\\\\\'b"
134  
135      def test_injection_attempt(self) -> None:
136          raw = "foo' OR name='bar"
137          escaped = escape_string_literal(raw)
138          assert "'" not in escaped.replace("\\'", "")
139  
140  
141  @pytest.mark.unit
142  class TestValidatePeriodDays:
143      def test_valid_int(self) -> None:
144          assert validate_period_days(30) == 30
145  
146      def test_min_value(self) -> None:
147          assert validate_period_days(1) == 1
148  
149      def test_zero_rejected(self) -> None:
150          with pytest.raises(GAQLValidationError):
151              validate_period_days(0)
152  
153      def test_negative_rejected(self) -> None:
154          with pytest.raises(GAQLValidationError):
155              validate_period_days(-1)
156  
157      def test_too_large_rejected(self) -> None:
158          with pytest.raises(GAQLValidationError):
159              validate_period_days(10_000)
160  
161      def test_custom_max(self) -> None:
162          assert validate_period_days(400, max_days=500) == 400
163          with pytest.raises(GAQLValidationError):
164              validate_period_days(600, max_days=500)
165  
166  
167  @pytest.mark.unit
168  class TestBuildInClause:
169      def test_single_value(self) -> None:
170          assert build_in_clause(["123"], "campaign_id") == "(123)"
171  
172      def test_multiple_values(self) -> None:
173          assert build_in_clause(["1", "2", "3"], "id") == "(1, 2, 3)"
174  
175      def test_empty_rejected(self) -> None:
176          with pytest.raises(GAQLValidationError):
177              build_in_clause([], "id")
178  
179      def test_injection_in_any_value_rejected(self) -> None:
180          with pytest.raises(GAQLValidationError):
181              build_in_clause(["1", "2); DROP TABLE campaign; --"], "id")