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")