fraiseql-server 2.16.0

HTTP server for FraiseQL v2 GraphQL engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! GraphQL request structure and validation tests.
//!
//! Tests that `GraphQLRequest` deserialization and `RequestValidator` work
//! correctly for well-formed and malformed inputs. These tests do not invoke
//! an executor or a database — they cover the HTTP input layer only.
//!
//! **Execution engine:** none
//! **Infrastructure:** none
//! **Parallelism:** safe
#![allow(clippy::unwrap_used, clippy::panic)] // Reason: test code, panics acceptable
#![allow(clippy::cast_precision_loss)] // Reason: test metrics use usize/u64→f64 for reporting
#![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
#![allow(clippy::cast_possible_truncation)] // Reason: test data values are small and bounded
#![allow(clippy::cast_possible_wrap)] // Reason: test data values are small and bounded
#![allow(clippy::cast_lossless)] // Reason: test code readability
#![allow(clippy::missing_panics_doc)] // Reason: test helper functions, panics are expected
#![allow(clippy::missing_errors_doc)] // Reason: test helper functions
#![allow(missing_docs)] // Reason: test code does not require documentation
#![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
#![allow(clippy::used_underscore_binding)] // Reason: test variables prefixed with _ by convention
#![allow(clippy::needless_pass_by_value)] // Reason: test helper signatures follow test patterns

use fraiseql_server::{
    error::GraphQLError, routes::graphql::GraphQLRequest, validation::RequestValidator,
};
use serde_json::json;

/// Test simple query without arguments
#[test]
fn test_simple_query_structure() {
    let request = GraphQLRequest {
        query:          Some("{ user { id } }".to_string()),
        variables:      None,
        operation_name: None,
        extensions:     None,
        document_id:    None,
    };

    assert_eq!(request.query.as_deref(), Some("{ user { id } }"));
    assert_eq!(request.variables, None);
    assert_eq!(request.operation_name, None);
}

/// Test query with variables
#[test]
fn test_query_with_variables() {
    let variables = json!({
        "userId": "123e4567-e89b-12d3-a456-426614174000",
        "limit": 10
    });

    let request = GraphQLRequest {
        query: Some("query($userId: ID!, $limit: Int!) { user(id: $userId) { posts(limit: $limit) { id } } }".to_string()),
        variables: Some(variables),
        operation_name: Some("GetUserPosts".to_string()),
        extensions: None,
        document_id: None,
    };

    assert_eq!(request.operation_name, Some("GetUserPosts".to_string()));

    let vars = request.variables.expect("variables should be present");
    assert_eq!(vars["userId"], "123e4567-e89b-12d3-a456-426614174000");
    assert_eq!(vars["limit"], 10);
}

/// Test query validation - simple queries should pass
#[test]
fn test_simple_query_validation() {
    let validator = RequestValidator::new();

    let simple_queries = vec![
        "{ user { id } }",
        "{ users { id name } }",
        "query { post { title } }",
        "query GetUser { user { id } }",
    ];

    for query in simple_queries {
        assert!(validator.validate_query(query).is_ok(), "Failed to validate query: {}", query);
    }
}

/// Test query validation with multiple fields
#[test]
fn test_multi_field_query_validation() {
    let validator = RequestValidator::new();

    let multi_field = "{
        users {
            id
            name
            email
        }
    }";

    validator
        .validate_query(multi_field)
        .unwrap_or_else(|e| panic!("Multi-field query should pass validation: {e}"));
}

/// Test nested query validation
#[test]
fn test_nested_query_validation() {
    let validator = RequestValidator::new();

    let nested = "{
        posts {
            id
            title
            author {
                id
                name
                email
            }
        }
    }";

    validator
        .validate_query(nested)
        .unwrap_or_else(|e| panic!("Nested query should pass validation: {e}"));
}

/// Test query depth validation with max depth setting
#[test]
fn test_query_depth_limit() {
    let validator = RequestValidator::new().with_max_depth(4);

    // Shallow (2 levels) should pass
    let shallow = "{ user { profile { name } } }";
    validator
        .validate_query(shallow)
        .unwrap_or_else(|e| panic!("Shallow query (2 levels) should pass depth limit of 4: {e}"));

    // At limit (3 levels) should pass
    let at_limit = "{ user { profile { settings { theme } } } }";
    validator
        .validate_query(at_limit)
        .unwrap_or_else(|e| panic!("Query at depth limit (3 levels) should pass: {e}"));

    // Over limit (5 levels) should fail
    let over_limit = "{ user { profile { settings { theme { dark { mode } } } } } }";
    assert!(
        validator.validate_query(over_limit).is_err(),
        "Query exceeding depth limit (5 levels, max 4) should be rejected"
    );
}

/// Test query complexity validation
#[test]
fn test_query_complexity_limit() {
    let validator = RequestValidator::new().with_max_complexity(10);

    // Simple (low complexity) should pass
    let simple = "{ user { id } }";
    validator
        .validate_query(simple)
        .unwrap_or_else(|e| panic!("Simple query should pass complexity limit: {e}"));

    // Moderate (within limit) should pass
    let moderate = "{ users { id name email posts { id title } } }";
    validator
        .validate_query(moderate)
        .unwrap_or_else(|e| panic!("Moderate query should pass complexity limit of 10: {e}"));
}

/// Test variables validation
#[test]
fn test_variables_validation() {
    let validator = RequestValidator::new();

    // Valid variables object
    let valid_vars = json!({
        "id": "123",
        "name": "John",
        "limit": 10
    });
    validator
        .validate_variables(Some(&valid_vars))
        .unwrap_or_else(|e| panic!("Valid variables object should pass validation: {e}"));

    // Empty variables
    let empty_vars = json!({});
    validator
        .validate_variables(Some(&empty_vars))
        .unwrap_or_else(|e| panic!("Empty variables object should pass validation: {e}"));

    // No variables
    validator
        .validate_variables(None)
        .unwrap_or_else(|e| panic!("No variables (None) should pass validation: {e}"));

    // Invalid: variables as array instead of object
    let invalid_array = json!([1, 2, 3]);
    assert!(
        validator.validate_variables(Some(&invalid_array)).is_err(),
        "Variables as array should be rejected"
    );

    // Invalid: variables as string
    let invalid_string = json!("some string");
    assert!(
        validator.validate_variables(Some(&invalid_string)).is_err(),
        "Variables as string should be rejected"
    );
}

/// Test pagination arguments validation.
///
/// #869: variable-valued pagination arguments are scored against the request's
/// actual variables. With reasonable values the query passes; with the
/// variables unavailable the multiplier fails closed at the clamp ceiling —
/// the old behaviour (scoring the neutral 1) is what let `first: $n` bypass
/// `max_query_complexity` entirely.
#[test]
fn test_pagination_query_validation() {
    let validator = RequestValidator::new();

    let with_pagination = "query($limit: Int!, $offset: Int!) {
        users(limit: $limit, offset: $offset) {
            id name
        }
    }";
    let doc = fraiseql_core::graphql::parse_graphql_document(with_pagination)
        .expect("valid query parses");

    let variables = json!({"limit": 25, "offset": 0});
    validator.validate_query_doc(&doc, Some(&variables)).unwrap_or_else(|e| {
        panic!("pagination query with resolvable variables should pass validation: {e}")
    });

    assert!(
        validator.validate_query_doc(&doc, None).is_err(),
        "without the variables, a variable-valued pagination argument must fail \
         closed at the clamp ceiling, not score the neutral 1 (#869)"
    );
}

/// Test empty query rejection
#[test]
fn test_empty_query_rejection() {
    let validator = RequestValidator::new();

    let empty_queries = vec!["", "   ", "\n", "\t"];

    for query in empty_queries {
        assert!(
            validator.validate_query(query).is_err(),
            "Should reject empty query: {:?}",
            query
        );
    }
}

/// Test that the structural validator rejects queries it can detect as invalid.
///
/// The `RequestValidator` uses AST-based validation via `graphql-parser`,
/// which catches both structural issues (malformed syntax) and security
/// concerns (excessive depth/complexity, fragment bypass).
#[test]
fn test_structural_validator_rejects_known_invalid() {
    let validator = RequestValidator::new().with_max_depth(3);

    // Excessive depth is rejected
    let too_deep = "{ a { b { c { d { e } } } } }";
    assert!(
        validator.validate_query(too_deep).is_err(),
        "Query exceeding max_depth should be rejected"
    );

    // Unclosed braces are now properly rejected by the AST parser
    let unclosed = "{ user { id";
    assert!(
        validator.validate_query(unclosed).is_err(),
        "Malformed queries with unclosed braces must be rejected"
    );
}

/// Test `GraphQLError` serializes to spec-compliant JSON format
#[test]
fn test_graphql_error_response_format() {
    let error = GraphQLError::parse("Unexpected token".to_string());
    let json = serde_json::to_value(&error).unwrap();

    assert_eq!(json["message"], "Unexpected token");
    assert_eq!(json["code"], "PARSE_ERROR");
}

/// Test query execution request structure
#[test]
fn test_graphql_request_deserialization() {
    let json_request = r#"{
        "query": "{ users { id name } }",
        "variables": {
            "limit": 10
        },
        "operationName": "GetUsers"
    }"#;

    let request: GraphQLRequest = serde_json::from_str(json_request).unwrap();

    assert_eq!(request.query.as_deref(), Some("{ users { id name } }"));
    let variables = request.variables.expect("variables should be present");
    assert_eq!(variables["limit"], 10);
    assert_eq!(request.operation_name, Some("GetUsers".to_string()));
}

/// Test minimal valid request
#[test]
fn test_minimal_graphql_request() {
    let json_request = r#"{"query": "{ users { id } }"}"#;

    let request: GraphQLRequest = serde_json::from_str(json_request).unwrap();

    assert_eq!(request.query.as_deref(), Some("{ users { id } }"));
    assert_eq!(request.variables, None);
    assert_eq!(request.operation_name, None);
}

/// Test request with all optional fields
#[test]
fn test_complete_graphql_request() {
    let json_request = r#"{
        "query": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
        "variables": { "id": "123" },
        "operationName": "GetUser"
    }"#;

    let request: GraphQLRequest = serde_json::from_str(json_request).unwrap();

    assert_eq!(request.operation_name, Some("GetUser".to_string()));
    assert_eq!(request.variables.unwrap().get("id").and_then(|v| v.as_str()), Some("123"));
}

/// Test request validation pipeline
#[test]
fn test_validation_pipeline() {
    let validator = RequestValidator::new();

    // Step 1: Parse request
    let request = GraphQLRequest {
        query:          Some("{ users { id name } }".to_string()),
        variables:      Some(json!({"limit": 10})),
        operation_name: None,
        extensions:     None,
        document_id:    None,
    };

    // Step 2: Validate query structure
    validator
        .validate_query(request.query.as_deref().unwrap())
        .unwrap_or_else(|e| panic!("Query structure validation should pass: {e}"));

    // Step 3: Validate variables format
    validator
        .validate_variables(request.variables.as_ref())
        .unwrap_or_else(|e| panic!("Variables format validation should pass: {e}"));
}

/// Test performance: multiple simple queries
#[test]
fn test_batch_query_validation() {
    let validator = RequestValidator::new();

    let queries = vec![
        "{ user { id } }",
        "{ users { id name } }",
        "{ posts { id title author { name } } }",
        "{ comments { id content } }",
    ];

    for query in queries {
        assert!(validator.validate_query(query).is_ok(), "Failed validation for: {}", query);
    }
}

/// Test that queries at various depths validate correctly against depth limits
#[test]
fn test_query_depth_acceptance_by_level() {
    // Use a depth limit of 3 to verify correct depth counting
    let validator = RequestValidator::new().with_max_depth(3);

    // These should pass (depth <= 3)
    let within_limit = vec![
        "{ id }",                        // depth 1
        "{ user { id } }",               // depth 2
        "{ user { profile { name } } }", // depth 3
    ];

    for query in within_limit {
        assert!(
            validator.validate_query(query).is_ok(),
            "Query should pass with max_depth=3: {query}"
        );
    }

    // This should fail (depth 4 > limit 3)
    let over_limit = "{ posts { author { posts { title } } } }";
    assert!(
        validator.validate_query(over_limit).is_err(),
        "Query at depth 4 should fail with max_depth=3"
    );
}