Skip to main content

fraiseql_core/graphql/
complexity.rs

1// GraphQL query complexity analysis — AST-based to prevent DoS attacks.
2//
3// Uses `graphql-parser` to walk the document tree, correctly handling
4// operation names, arguments, fragment spreads, and aliases (which a
5// character-scan approach cannot distinguish from field names).
6
7use graphql_parser::query::{
8    Definition, Document, FragmentDefinition, OperationDefinition, Selection, SelectionSet,
9};
10
11/// Default maximum number of aliases per query (alias amplification protection).
12///
13/// This constant is the single source of truth used by [`ComplexityConfig`],
14/// [`RequestValidator`], the server HTTP handler, and the CLI `explain` command.
15pub const DEFAULT_MAX_ALIASES: usize = 30;
16
17/// Maximum number of variables per request (`DoS` protection).
18///
19/// A single GraphQL request with thousands of variables can cause excessive memory
20/// allocation during deserialization and variable injection. This constant caps
21/// the number of top-level keys in the `variables` JSON object.
22pub const MAX_VARIABLES_COUNT: usize = 1_000;
23
24/// Configuration for query complexity limits.
25#[derive(Debug, Clone)]
26pub struct ComplexityConfig {
27    /// Maximum query depth (nesting level) — default: 10
28    pub max_depth:      usize,
29    /// Maximum complexity score — default: 100
30    pub max_complexity: usize,
31    /// Maximum number of field aliases per query — default: 30
32    pub max_aliases:    usize,
33}
34
35impl Default for ComplexityConfig {
36    fn default() -> Self {
37        Self {
38            max_depth:      10,
39            max_complexity: 100,
40            max_aliases:    DEFAULT_MAX_ALIASES,
41        }
42    }
43}
44
45/// Metrics returned by the AST-based analyzer.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct QueryMetrics {
48    /// Maximum selection-set nesting depth.
49    pub depth:       usize,
50    /// Total complexity score (accounts for pagination multipliers).
51    pub complexity:  usize,
52    /// Number of aliased fields in the document.
53    pub alias_count: usize,
54}
55
56/// GraphQL query validation error types (depth, complexity, aliases).
57#[derive(Debug, thiserror::Error, Clone)]
58#[non_exhaustive]
59pub enum ComplexityValidationError {
60    /// Query exceeds maximum allowed depth.
61    #[error("Query exceeds maximum depth of {max_depth}: depth = {actual_depth}")]
62    QueryTooDeep {
63        /// Maximum allowed depth
64        max_depth:    usize,
65        /// Actual query depth
66        actual_depth: usize,
67    },
68
69    /// Query exceeds maximum complexity score.
70    #[error("Query exceeds maximum complexity of {max_complexity}: score = {actual_complexity}")]
71    QueryTooComplex {
72        /// Maximum allowed complexity
73        max_complexity:    usize,
74        /// Actual query complexity
75        actual_complexity: usize,
76    },
77
78    /// Query contains too many aliases (alias amplification attack).
79    #[error("Query exceeds maximum alias count of {max_aliases}: count = {actual_aliases}")]
80    TooManyAliases {
81        /// Maximum allowed alias count
82        max_aliases:    usize,
83        /// Actual alias count
84        actual_aliases: usize,
85    },
86
87    /// Invalid query variables.
88    #[error("Invalid variables: {0}")]
89    InvalidVariables(String),
90
91    /// Malformed GraphQL query.
92    #[error("Malformed GraphQL query: {0}")]
93    MalformedQuery(String),
94}
95
96/// AST-based GraphQL request validator.
97///
98/// Uses `graphql-parser` to walk the full document tree. Correctly handles
99/// operation names, arguments, fragment spreads, inline fragments, and aliases —
100/// none of which a character-scan can distinguish from field names.
101#[derive(Debug, Clone)]
102pub struct RequestValidator {
103    /// Maximum query depth allowed.
104    max_depth:             usize,
105    /// Maximum query complexity score allowed.
106    max_complexity:        usize,
107    /// Maximum number of field aliases per query (alias amplification protection).
108    max_aliases_per_query: usize,
109    /// Enable query depth validation.
110    validate_depth:        bool,
111    /// Enable query complexity validation.
112    validate_complexity:   bool,
113}
114
115impl RequestValidator {
116    /// Create a new validator with default settings.
117    #[must_use]
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Create from a `ComplexityConfig`.
123    #[must_use]
124    pub const fn from_config(config: &ComplexityConfig) -> Self {
125        Self {
126            max_depth:             config.max_depth,
127            max_complexity:        config.max_complexity,
128            max_aliases_per_query: config.max_aliases,
129            validate_depth:        true,
130            validate_complexity:   true,
131        }
132    }
133
134    /// Set maximum query depth.
135    #[must_use]
136    pub const fn with_max_depth(mut self, max_depth: usize) -> Self {
137        self.max_depth = max_depth;
138        self
139    }
140
141    /// Set maximum query complexity.
142    #[must_use]
143    pub const fn with_max_complexity(mut self, max_complexity: usize) -> Self {
144        self.max_complexity = max_complexity;
145        self
146    }
147
148    /// Enable/disable depth validation.
149    #[must_use]
150    pub const fn with_depth_validation(mut self, enabled: bool) -> Self {
151        self.validate_depth = enabled;
152        self
153    }
154
155    /// Enable/disable complexity validation.
156    #[must_use]
157    pub const fn with_complexity_validation(mut self, enabled: bool) -> Self {
158        self.validate_complexity = enabled;
159        self
160    }
161
162    /// Set maximum number of aliases per query.
163    #[must_use]
164    pub const fn with_max_aliases(mut self, max_aliases: usize) -> Self {
165        self.max_aliases_per_query = max_aliases;
166        self
167    }
168
169    /// Compute query metrics without enforcing any limits.
170    ///
171    /// Returns [`QueryMetrics`] for the query.
172    /// Used by CLI tooling (`explain`, `cost` commands) where raw metrics
173    /// are needed without a hard rejection.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`ComplexityValidationError::MalformedQuery`] if the query cannot be parsed.
178    pub fn analyze(&self, query: &str) -> Result<QueryMetrics, ComplexityValidationError> {
179        if query.trim().is_empty() {
180            return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
181        }
182        let document = graphql_parser::parse_query::<String>(query)
183            .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
184        let fragments = collect_fragments(&document);
185        Ok(QueryMetrics {
186            depth:       self.calculate_depth_ast(&document, &fragments),
187            complexity:  self.calculate_complexity_ast(&document, &fragments),
188            alias_count: self.count_aliases_ast(&document),
189        })
190    }
191
192    /// Validate a GraphQL query string, enforcing configured limits.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`ComplexityValidationError`] if the query violates any validation rules.
197    pub fn validate_query(&self, query: &str) -> Result<(), ComplexityValidationError> {
198        if query.trim().is_empty() {
199            return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
200        }
201
202        // Skip AST parsing only when depth, complexity, AND alias checks are all disabled.
203        // The alias amplification check is a distinct DoS vector: it must run even when
204        // depth and complexity validation are both turned off.
205        if !self.validate_depth && !self.validate_complexity && self.max_aliases_per_query == 0 {
206            return Ok(());
207        }
208
209        let document = graphql_parser::parse_query::<String>(query)
210            .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
211        let fragments = collect_fragments(&document);
212
213        if self.validate_depth {
214            let depth = self.calculate_depth_ast(&document, &fragments);
215            if depth > self.max_depth {
216                return Err(ComplexityValidationError::QueryTooDeep {
217                    max_depth:    self.max_depth,
218                    actual_depth: depth,
219                });
220            }
221        }
222
223        if self.validate_complexity {
224            let complexity = self.calculate_complexity_ast(&document, &fragments);
225            if complexity > self.max_complexity {
226                return Err(ComplexityValidationError::QueryTooComplex {
227                    max_complexity:    self.max_complexity,
228                    actual_complexity: complexity,
229                });
230            }
231        }
232
233        let alias_count = self.count_aliases_ast(&document);
234        if alias_count > self.max_aliases_per_query {
235            return Err(ComplexityValidationError::TooManyAliases {
236                max_aliases:    self.max_aliases_per_query,
237                actual_aliases: alias_count,
238            });
239        }
240
241        Ok(())
242    }
243
244    /// Validate variables JSON.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`ComplexityValidationError`] if variables are not a JSON object or exceed
249    /// [`MAX_VARIABLES_COUNT`].
250    ///
251    /// # Panics
252    ///
253    /// Cannot panic in practice — the `expect` on `as_object()` is guarded
254    /// by a preceding `is_object()` check that returns `Err` first.
255    pub fn validate_variables(
256        &self,
257        variables: Option<&serde_json::Value>,
258    ) -> Result<(), ComplexityValidationError> {
259        if let Some(vars) = variables {
260            if !vars.is_object() {
261                return Err(ComplexityValidationError::InvalidVariables(
262                    "Variables must be an object".to_string(),
263                ));
264            }
265            // Reason: `is_object()` check on the line above guarantees this is an object;
266            // `as_object()` cannot return None here.
267            let obj = vars.as_object().expect("invariant: vars.is_object() checked above");
268            if obj.len() > MAX_VARIABLES_COUNT {
269                return Err(ComplexityValidationError::InvalidVariables(format!(
270                    "Too many variables: {} exceeds maximum of {}",
271                    obj.len(),
272                    MAX_VARIABLES_COUNT
273                )));
274            }
275        }
276        Ok(())
277    }
278
279    fn calculate_depth_ast(
280        &self,
281        document: &Document<String>,
282        fragments: &[&FragmentDefinition<String>],
283    ) -> usize {
284        document
285            .definitions
286            .iter()
287            .map(|def| match def {
288                Definition::Operation(op) => match op {
289                    OperationDefinition::Query(q) => {
290                        self.selection_set_depth(&q.selection_set, fragments, 0)
291                    },
292                    OperationDefinition::Mutation(m) => {
293                        self.selection_set_depth(&m.selection_set, fragments, 0)
294                    },
295                    OperationDefinition::Subscription(s) => {
296                        self.selection_set_depth(&s.selection_set, fragments, 0)
297                    },
298                    OperationDefinition::SelectionSet(ss) => {
299                        self.selection_set_depth(ss, fragments, 0)
300                    },
301                },
302                Definition::Fragment(f) => self.selection_set_depth(&f.selection_set, fragments, 0),
303            })
304            .max()
305            .unwrap_or(0)
306    }
307
308    fn selection_set_depth(
309        &self,
310        selection_set: &SelectionSet<String>,
311        fragments: &[&FragmentDefinition<String>],
312        recursion_depth: usize,
313    ) -> usize {
314        if recursion_depth > 32 {
315            return self.max_depth + 1;
316        }
317        if selection_set.items.is_empty() {
318            return 0;
319        }
320        let max_child = selection_set
321            .items
322            .iter()
323            .map(|sel| match sel {
324                Selection::Field(field) => {
325                    if field.selection_set.items.is_empty() {
326                        0
327                    } else {
328                        self.selection_set_depth(&field.selection_set, fragments, recursion_depth)
329                    }
330                },
331                Selection::InlineFragment(inline) => {
332                    self.selection_set_depth(&inline.selection_set, fragments, recursion_depth)
333                },
334                Selection::FragmentSpread(spread) => {
335                    if let Some(frag) = fragments.iter().find(|f| f.name == spread.fragment_name) {
336                        self.selection_set_depth(
337                            &frag.selection_set,
338                            fragments,
339                            recursion_depth + 1,
340                        )
341                    } else {
342                        self.max_depth
343                    }
344                },
345            })
346            .max()
347            .unwrap_or(0);
348        1 + max_child
349    }
350
351    fn calculate_complexity_ast(
352        &self,
353        document: &Document<String>,
354        fragments: &[&FragmentDefinition<String>],
355    ) -> usize {
356        document
357            .definitions
358            .iter()
359            .map(|def| match def {
360                Definition::Operation(op) => match op {
361                    OperationDefinition::Query(q) => {
362                        self.selection_set_complexity(&q.selection_set, fragments, 0)
363                    },
364                    OperationDefinition::Mutation(m) => {
365                        self.selection_set_complexity(&m.selection_set, fragments, 0)
366                    },
367                    OperationDefinition::Subscription(s) => {
368                        self.selection_set_complexity(&s.selection_set, fragments, 0)
369                    },
370                    OperationDefinition::SelectionSet(ss) => {
371                        self.selection_set_complexity(ss, fragments, 0)
372                    },
373                },
374                Definition::Fragment(_) => 0,
375            })
376            .sum()
377    }
378
379    fn selection_set_complexity(
380        &self,
381        selection_set: &SelectionSet<String>,
382        fragments: &[&FragmentDefinition<String>],
383        recursion_depth: usize,
384    ) -> usize {
385        if recursion_depth > 32 {
386            return self.max_complexity + 1;
387        }
388        selection_set
389            .items
390            .iter()
391            .map(|sel| match sel {
392                Selection::Field(field) => {
393                    let multiplier = extract_limit_multiplier(&field.arguments);
394                    if field.selection_set.items.is_empty() {
395                        1
396                    } else {
397                        let nested = self.selection_set_complexity(
398                            &field.selection_set,
399                            fragments,
400                            recursion_depth,
401                        );
402                        1 + nested * multiplier
403                    }
404                },
405                Selection::InlineFragment(inline) => {
406                    self.selection_set_complexity(&inline.selection_set, fragments, recursion_depth)
407                },
408                Selection::FragmentSpread(spread) => {
409                    if let Some(frag) = fragments.iter().find(|f| f.name == spread.fragment_name) {
410                        self.selection_set_complexity(
411                            &frag.selection_set,
412                            fragments,
413                            recursion_depth + 1,
414                        )
415                    } else {
416                        10
417                    }
418                },
419            })
420            .sum()
421    }
422
423    fn count_aliases_ast(&self, document: &Document<String>) -> usize {
424        document
425            .definitions
426            .iter()
427            .map(|def| match def {
428                Definition::Operation(op) => {
429                    let ss = match op {
430                        OperationDefinition::Query(q) => &q.selection_set,
431                        OperationDefinition::Mutation(m) => &m.selection_set,
432                        OperationDefinition::Subscription(s) => &s.selection_set,
433                        OperationDefinition::SelectionSet(ss) => ss,
434                    };
435                    count_aliases_in_selection_set(ss)
436                },
437                Definition::Fragment(f) => count_aliases_in_selection_set(&f.selection_set),
438            })
439            .sum()
440    }
441}
442
443impl Default for RequestValidator {
444    fn default() -> Self {
445        Self {
446            max_depth:             10,
447            max_complexity:        100,
448            max_aliases_per_query: DEFAULT_MAX_ALIASES,
449            validate_depth:        true,
450            validate_complexity:   true,
451        }
452    }
453}
454
455/// Collect all fragment definitions from a parsed document.
456fn collect_fragments<'a>(
457    document: &'a Document<'a, String>,
458) -> Vec<&'a FragmentDefinition<'a, String>> {
459    document
460        .definitions
461        .iter()
462        .filter_map(|def| {
463            if let Definition::Fragment(f) = def {
464                Some(f)
465            } else {
466                None
467            }
468        })
469        .collect()
470}
471
472/// Extract pagination limit from field arguments to use as a cost multiplier.
473fn extract_limit_multiplier(arguments: &[(String, graphql_parser::query::Value<String>)]) -> usize {
474    for (name, value) in arguments {
475        if matches!(name.as_str(), "first" | "limit" | "take" | "last") {
476            if let graphql_parser::query::Value::Int(n) = value {
477                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
478                // Reason: value is clamped to [1, 100] immediately after; truncation and sign loss
479                // are safe
480                let limit = n.as_i64().unwrap_or(10) as usize;
481                return limit.clamp(1, 100);
482            }
483        }
484    }
485    1
486}
487
488/// Recursively count aliases in a selection set.
489fn count_aliases_in_selection_set(selection_set: &SelectionSet<String>) -> usize {
490    selection_set
491        .items
492        .iter()
493        .map(|sel| match sel {
494            Selection::Field(field) => {
495                let self_alias = usize::from(field.alias.is_some());
496                self_alias + count_aliases_in_selection_set(&field.selection_set)
497            },
498            Selection::InlineFragment(inline) => {
499                count_aliases_in_selection_set(&inline.selection_set)
500            },
501            Selection::FragmentSpread(_) => 0,
502        })
503        .sum()
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    // ── Regression tests: operation names and arguments must NOT be counted ──
511
512    #[test]
513    fn test_operation_name_not_counted_as_field() {
514        let validator = RequestValidator::default();
515        let metrics = validator
516            .analyze("query getUserPosts { users { id name } }")
517            .expect("valid query");
518        // "getUserPosts" is the operation name — must not count as a field.
519        // Fields: users→(id, name) = complexity 3
520        assert!(
521            metrics.complexity <= 10,
522            "operation name must not inflate complexity; got {metrics:?}"
523        );
524    }
525
526    #[test]
527    fn test_arguments_not_counted_as_fields() {
528        let validator = RequestValidator::default();
529        let metrics = validator
530            .analyze("{ users(limit: 10, offset: 0) { id } }")
531            .expect("valid query");
532        // "limit" and "offset" are arguments, NOT fields.
533        assert!(
534            metrics.complexity < 50,
535            "arguments must not be counted as fields; got {metrics:?}"
536        );
537    }
538
539    // ── Depth ──
540
541    #[test]
542    fn test_simple_query_depth() {
543        let validator = RequestValidator::default();
544        let metrics = validator.analyze("{ users { id name } }").expect("valid");
545        assert_eq!(metrics.depth, 2);
546    }
547
548    #[test]
549    fn test_deeply_nested_query_depth() {
550        let validator = RequestValidator::default();
551        let query = "{ a { b { c { d { e { f { g { h } } } } } } } }";
552        let metrics = validator.analyze(query).expect("valid");
553        assert!(metrics.depth >= 8, "expected depth ≥ 8, got {}", metrics.depth);
554    }
555
556    #[test]
557    fn test_depth_validation_pass() {
558        let validator = RequestValidator::default().with_max_depth(5);
559        validator
560            .validate_query("{ user { id } }")
561            .unwrap_or_else(|e| panic!("expected Ok: {e}"));
562    }
563
564    #[test]
565    fn test_depth_validation_fail() {
566        let validator = RequestValidator::default().with_max_depth(3);
567        let deep = "{ user { profile { settings { theme } } } }";
568        let result = validator.validate_query(deep);
569        assert!(
570            matches!(result, Err(ComplexityValidationError::QueryTooDeep { .. })),
571            "expected QueryTooDeep, got: {result:?}"
572        );
573    }
574
575    // ── Fragment depth bypass ──
576
577    #[test]
578    fn test_fragment_depth_bypass_blocked() {
579        let validator = RequestValidator::new().with_max_depth(3);
580        let query = "
581            fragment Deep on User { a { b { c { d { e } } } } }
582            query { ...Deep }
583        ";
584        assert!(
585            validator.validate_query(query).is_err(),
586            "fragment depth bypass must be blocked"
587        );
588    }
589
590    #[test]
591    fn test_shallow_fragment_allowed() {
592        let validator = RequestValidator::new().with_max_depth(5);
593        let query = "
594            fragment UserFields on User { id name email }
595            query { user { ...UserFields } }
596        ";
597        validator.validate_query(query).unwrap_or_else(|e| panic!("expected Ok: {e}"));
598    }
599
600    // ── Complexity ──
601
602    #[test]
603    fn test_complexity_validation_pass() {
604        let validator = RequestValidator::default().with_max_complexity(20);
605        validator
606            .validate_query("query { user { id name email } }")
607            .unwrap_or_else(|e| panic!("expected Ok: {e}"));
608    }
609
610    #[test]
611    fn test_pagination_limit_multiplier() {
612        let validator = RequestValidator::new().with_max_complexity(50);
613        let query = "query { users(first: 100) { id name } }";
614        assert!(
615            validator.validate_query(query).is_err(),
616            "high pagination limits must increase complexity"
617        );
618    }
619
620    #[test]
621    fn test_nested_list_multiplier() {
622        let validator = RequestValidator::new().with_max_complexity(50);
623        let query = "query { users(first: 10) { friends(first: 10) { id } } }";
624        assert!(
625            validator.validate_query(query).is_err(),
626            "nested list multipliers must compound"
627        );
628    }
629
630    // ── Aliases ──
631
632    #[test]
633    fn test_alias_count_within_limit() {
634        let validator = RequestValidator::new().with_max_aliases(5);
635        let query = "query { a: user { id } b: user { id } c: user { id } }";
636        validator.validate_query(query).unwrap_or_else(|e| panic!("expected Ok: {e}"));
637    }
638
639    #[test]
640    fn test_alias_count_exceeds_limit() {
641        let validator = RequestValidator::new().with_max_aliases(2);
642        let query = "query { a: user { id } b: user { id } c: user { id } }";
643        assert!(
644            matches!(
645                validator.validate_query(query),
646                Err(ComplexityValidationError::TooManyAliases {
647                    actual_aliases: 3,
648                    ..
649                })
650            ),
651            "should report alias count"
652        );
653    }
654
655    #[test]
656    fn test_default_alias_limit_is_30() {
657        let validator = RequestValidator::new();
658        let fields_30: String = (0..30).fold(String::new(), |mut s, i| {
659            use std::fmt::Write;
660            let _ = write!(s, "f{i}: user {{ id }} ");
661            s
662        });
663        validator
664            .validate_query(&format!("query {{ {fields_30} }}"))
665            .unwrap_or_else(|e| panic!("expected Ok for 30 aliases: {e}"));
666
667        let fields_31: String = (0..31).fold(String::new(), |mut s, i| {
668            use std::fmt::Write;
669            let _ = write!(s, "f{i}: user {{ id }} ");
670            s
671        });
672        let result_31 = validator.validate_query(&format!("query {{ {fields_31} }}"));
673        assert!(
674            matches!(result_31, Err(ComplexityValidationError::TooManyAliases { .. })),
675            "expected TooManyAliases for 31 aliases, got: {result_31:?}"
676        );
677    }
678
679    // ── Parse errors ──
680
681    #[test]
682    fn test_empty_query_rejected() {
683        let validator = RequestValidator::new();
684        let r1 = validator.validate_query("");
685        assert!(
686            matches!(r1, Err(ComplexityValidationError::MalformedQuery(_))),
687            "expected MalformedQuery for empty string, got: {r1:?}"
688        );
689        let r2 = validator.validate_query("   ");
690        assert!(
691            matches!(r2, Err(ComplexityValidationError::MalformedQuery(_))),
692            "expected MalformedQuery for whitespace, got: {r2:?}"
693        );
694    }
695
696    #[test]
697    fn test_malformed_query_rejected() {
698        let validator = RequestValidator::new();
699        let result = validator.validate_query("{ invalid query {{}}");
700        assert!(
701            matches!(result, Err(ComplexityValidationError::MalformedQuery(_))),
702            "expected MalformedQuery, got: {result:?}"
703        );
704    }
705
706    // ── Variables ──
707
708    #[test]
709    fn test_valid_variables() {
710        let validator = RequestValidator::new();
711        let vars = serde_json::json!({"id": "123"});
712        validator
713            .validate_variables(Some(&vars))
714            .unwrap_or_else(|e| panic!("expected Ok: {e}"));
715    }
716
717    #[test]
718    fn test_invalid_variables_not_object() {
719        let validator = RequestValidator::new();
720        let vars = serde_json::json!([1, 2, 3]);
721        let result = validator.validate_variables(Some(&vars));
722        assert!(
723            matches!(result, Err(ComplexityValidationError::InvalidVariables(_))),
724            "expected InvalidVariables, got: {result:?}"
725        );
726    }
727
728    #[test]
729    fn test_validate_variables_too_many() {
730        let validator = RequestValidator::new();
731        // Build an object with MAX_VARIABLES_COUNT + 1 keys — must be rejected.
732        let vars: serde_json::Value = serde_json::Value::Object(
733            (0..=MAX_VARIABLES_COUNT)
734                .map(|i| (format!("v{i}"), serde_json::Value::Null))
735                .collect(),
736        );
737        let result = validator.validate_variables(Some(&vars));
738        assert!(
739            matches!(result, Err(ComplexityValidationError::InvalidVariables(_))),
740            "expected InvalidVariables for too-many-variables, got: {result:?}"
741        );
742    }
743
744    #[test]
745    fn test_validate_variables_at_limit_is_ok() {
746        let validator = RequestValidator::new();
747        // Exactly MAX_VARIABLES_COUNT keys — must be accepted.
748        let vars: serde_json::Value = serde_json::Value::Object(
749            (0..MAX_VARIABLES_COUNT)
750                .map(|i| (format!("v{i}"), serde_json::Value::Null))
751                .collect(),
752        );
753        validator
754            .validate_variables(Some(&vars))
755            .unwrap_or_else(|e| panic!("expected Ok at limit, got: {e}"));
756    }
757
758    // ── Disabled validation ──
759
760    #[test]
761    fn test_disable_depth_and_complexity_validation() {
762        let validator = RequestValidator::new()
763            .with_depth_validation(false)
764            .with_complexity_validation(false)
765            .with_max_depth(1)
766            .with_max_complexity(1);
767        let deep = "{ a { b { c { d { e { f } } } } } }";
768        validator
769            .validate_query(deep)
770            .unwrap_or_else(|e| panic!("expected Ok when depth/complexity disabled: {e}"));
771    }
772
773    // ── Boundary / mutation-test sentinels ──
774    //
775    // Each test below is written to catch a specific surviving or predicted mutant.
776    // Do not remove or weaken these tests without running `cargo mutants` first.
777
778    // Guards: complexity > max (not >=, not ==)
779    #[test]
780    fn test_complexity_at_limit_is_allowed() {
781        // { a b c } has complexity 3. max=3 must PASS (> not >=).
782        let validator = RequestValidator::new().with_max_complexity(3);
783        validator
784            .validate_query("query { a b c }")
785            .unwrap_or_else(|e| panic!("complexity == max must be allowed: {e}"));
786    }
787
788    #[test]
789    fn test_complexity_just_over_limit_is_rejected() {
790        // { a b c d } has complexity 4 > max=3, must FAIL.
791        let validator = RequestValidator::new().with_max_complexity(3);
792        assert!(
793            matches!(
794                validator.validate_query("query { a b c d }"),
795                Err(ComplexityValidationError::QueryTooComplex { .. })
796            ),
797            "complexity > max must be rejected"
798        );
799    }
800
801    // Guards: depth > max (not >=, not ==)
802    #[test]
803    fn test_depth_at_limit_is_allowed() {
804        // { a { b { c } } } has depth 3. max_depth=3 must PASS (> not >=).
805        let validator = RequestValidator::default().with_max_depth(3);
806        validator
807            .validate_query("{ a { b { c } } }")
808            .unwrap_or_else(|e| panic!("depth == max must be allowed: {e}"));
809    }
810
811    #[test]
812    fn test_depth_just_over_limit_is_rejected() {
813        // { a { b { c { d } } } } has depth 4 > max=3, must FAIL.
814        let validator = RequestValidator::default().with_max_depth(3);
815        assert!(
816            matches!(
817                validator.validate_query("{ a { b { c { d } } } }"),
818                Err(ComplexityValidationError::QueryTooDeep { .. })
819            ),
820            "depth > max must be rejected"
821        );
822    }
823
824    // Guards: early-return condition is `&&` not `||`, and requires all three
825    #[test]
826    fn test_skip_validation_requires_aliases_also_zero() {
827        // Depth and complexity disabled but aliases still active: alias check must still run.
828        // Guards the `||` → `&&` mutation at the early-return gate.
829        let validator = RequestValidator::new()
830            .with_depth_validation(false)
831            .with_complexity_validation(false)
832            .with_max_aliases(2);
833        let query = "query { a: user { id } b: user { id } c: user { id } }";
834        assert!(
835            validator.validate_query(query).is_err(),
836            "alias check must run even when depth/complexity validation is disabled"
837        );
838    }
839
840    #[test]
841    fn test_early_return_requires_depth_disabled() {
842        // Guards `delete !` on `!self.validate_depth`: when depth is still on, the early-return
843        // must not fire even if complexity is off and aliases == 0.
844        let validator = RequestValidator::new()
845            .with_depth_validation(true)
846            .with_complexity_validation(false)
847            .with_max_aliases(0)
848            .with_max_depth(2);
849        assert!(
850            matches!(
851                validator.validate_query("{ a { b { c } } }"),
852                Err(ComplexityValidationError::QueryTooDeep { .. })
853            ),
854            "depth validation must still run when only complexity is disabled"
855        );
856    }
857
858    #[test]
859    fn test_early_return_requires_complexity_disabled() {
860        // Guards `delete !` on `!self.validate_complexity`: when complexity is still on,
861        // the early-return must not fire even if depth is off and aliases == 0.
862        let validator = RequestValidator::new()
863            .with_depth_validation(false)
864            .with_complexity_validation(true)
865            .with_max_aliases(0)
866            .with_max_complexity(2);
867        assert!(
868            matches!(
869                validator.validate_query("query { users(first: 100) { id name } }"),
870                Err(ComplexityValidationError::QueryTooComplex { .. })
871            ),
872            "complexity validation must still run when only depth is disabled"
873        );
874    }
875
876    // Guards: recursion guard `> 32` in fragment depth / complexity helpers
877    #[test]
878    fn test_deep_fragment_recursion_guard() {
879        // A chain of 34 fragment spreads exceeds the recursion guard (> 32).
880        // The guard must return max_depth+1 (not max_depth or max_depth-1),
881        // ensuring the query is rejected rather than silently allowed.
882        let validator = RequestValidator::new().with_max_depth(5);
883        let mut query = String::from("query { ...F0 }\n");
884        for i in 0..34_usize {
885            use std::fmt::Write;
886            let _ = writeln!(query, "fragment F{i} on T {{ ...F{} }}", i + 1);
887        }
888        query.push_str("fragment F34 on T { id }\n");
889        assert!(
890            validator.validate_query(&query).is_err(),
891            "deeply nested fragment chain must be rejected by recursion guard"
892        );
893    }
894
895    // Guards: alias `+` not `-` in count_aliases_in_selection_set
896    #[test]
897    fn test_nested_aliases_counted_correctly() {
898        // Aliases nested inside another field's selection set must be summed, not subtracted.
899        // { a: user { id } b: user { c: name d: email } } has 4 aliases total.
900        let validator = RequestValidator::new().with_max_aliases(3);
901        assert!(
902            matches!(
903                validator.validate_query("query { a: user { id } b: user { c: name d: email } }"),
904                Err(ComplexityValidationError::TooManyAliases {
905                    actual_aliases: 4,
906                    ..
907                })
908            ),
909            "nested aliases must be summed, not subtracted"
910        );
911    }
912
913    // ── from_config ──
914
915    #[test]
916    fn test_from_config() {
917        let config = ComplexityConfig {
918            max_depth:      5,
919            max_complexity: 20,
920            max_aliases:    3,
921        };
922        let validator = RequestValidator::from_config(&config);
923        // Depth-6 query should fail
924        let result = validator.validate_query("{ a { b { c { d { e { f } } } } } }");
925        assert!(
926            matches!(result, Err(ComplexityValidationError::QueryTooDeep { .. })),
927            "expected QueryTooDeep for depth-6 query with max 5, got: {result:?}"
928        );
929    }
930}