1use crate::config::CodeModeConfig;
11use crate::explanation::{ExplanationGenerator, TemplateExplanationGenerator};
12use crate::graphql::{GraphQLQueryInfo, GraphQLValidator};
13use crate::policy::{OperationEntity, PolicyEvaluator};
14use crate::token::{compute_context_hash, HmacTokenGenerator, TokenGenerator, TokenSecret};
15use crate::types::{
16 PolicyViolation, TokenError, UnifiedAction, ValidationError, ValidationMetadata,
17 ValidationResult,
18};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::Instant;
22
23#[cfg(feature = "openapi-code-mode")]
24use crate::javascript::{JavaScriptCodeInfo, JavaScriptValidator};
25
26static NO_POLICY_WARNING_LOGGED: AtomicBool = AtomicBool::new(false);
28
29fn warn_no_policy_configured() {
31 if !NO_POLICY_WARNING_LOGGED.swap(true, Ordering::SeqCst) {
32 tracing::warn!(
33 target: "code_mode",
34 "CODE MODE SECURITY WARNING: Code Mode is enabled but no policy evaluator \
35 is configured. Only basic config checks (allow_mutations, max_depth, etc.) will be \
36 performed. This provides NO real authorization policy evaluation. \
37 For production deployments, configure a policy evaluator (AVP or local Cedar)."
38 );
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct ValidationContext {
45 pub user_id: String,
47
48 pub session_id: String,
50
51 pub schema_hash: String,
53
54 pub permissions_hash: String,
56}
57
58impl ValidationContext {
59 pub fn new(
61 user_id: impl Into<String>,
62 session_id: impl Into<String>,
63 schema_hash: impl Into<String>,
64 permissions_hash: impl Into<String>,
65 ) -> Self {
66 Self {
67 user_id: user_id.into(),
68 session_id: session_id.into(),
69 schema_hash: schema_hash.into(),
70 permissions_hash: permissions_hash.into(),
71 }
72 }
73
74 pub fn context_hash(&self) -> String {
76 compute_context_hash(&self.schema_hash, &self.permissions_hash)
77 }
78}
79
80pub struct ValidationPipeline<
82 T: TokenGenerator = HmacTokenGenerator,
83 E: ExplanationGenerator = TemplateExplanationGenerator,
84> {
85 config: CodeModeConfig,
86 graphql_validator: GraphQLValidator,
87 #[cfg(feature = "openapi-code-mode")]
88 javascript_validator: JavaScriptValidator,
89 token_generator: T,
90 explanation_generator: E,
91 policy_evaluator: Option<Arc<dyn PolicyEvaluator>>,
92}
93
94impl ValidationPipeline<HmacTokenGenerator, TemplateExplanationGenerator> {
95 pub fn new(
105 config: CodeModeConfig,
106 token_secret: impl Into<Vec<u8>>,
107 ) -> Result<Self, TokenError> {
108 if config.enabled {
109 warn_no_policy_configured();
110 }
111
112 Ok(Self {
113 graphql_validator: GraphQLValidator::default(),
114 #[cfg(feature = "openapi-code-mode")]
115 javascript_validator: JavaScriptValidator::default()
116 .with_sdk_operations(config.sdk_operations.clone()),
117 token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
118 explanation_generator: TemplateExplanationGenerator::new(),
119 policy_evaluator: None,
120 config,
121 })
122 }
123
124 pub fn from_token_secret(
143 config: CodeModeConfig,
144 secret: &TokenSecret,
145 ) -> Result<Self, TokenError> {
146 Self::new(config, secret.expose_secret().to_vec())
147 }
148
149 pub fn with_policy_evaluator(
156 config: CodeModeConfig,
157 token_secret: impl Into<Vec<u8>>,
158 evaluator: Arc<dyn PolicyEvaluator>,
159 ) -> Result<Self, TokenError> {
160 Ok(Self {
161 graphql_validator: GraphQLValidator::default(),
162 #[cfg(feature = "openapi-code-mode")]
163 javascript_validator: JavaScriptValidator::default()
164 .with_sdk_operations(config.sdk_operations.clone()),
165 token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
166 explanation_generator: TemplateExplanationGenerator::new(),
167 policy_evaluator: Some(evaluator),
168 config,
169 })
170 }
171
172 pub fn from_token_secret_with_policy(
182 config: CodeModeConfig,
183 secret: &TokenSecret,
184 evaluator: Arc<dyn PolicyEvaluator>,
185 ) -> Result<Self, TokenError> {
186 Self::with_policy_evaluator(config, secret.expose_secret().to_vec(), evaluator)
187 }
188}
189
190impl<T: TokenGenerator, E: ExplanationGenerator> ValidationPipeline<T, E> {
191 pub fn with_generators(
193 config: CodeModeConfig,
194 token_generator: T,
195 explanation_generator: E,
196 ) -> Self {
197 Self {
198 graphql_validator: GraphQLValidator::default(),
199 #[cfg(feature = "openapi-code-mode")]
200 javascript_validator: JavaScriptValidator::default()
201 .with_sdk_operations(config.sdk_operations.clone()),
202 token_generator,
203 explanation_generator,
204 policy_evaluator: None,
205 config,
206 }
207 }
208
209 pub fn set_policy_evaluator(&mut self, evaluator: Arc<dyn PolicyEvaluator>) {
211 self.policy_evaluator = Some(evaluator);
212 }
213
214 pub fn has_policy_evaluator(&self) -> bool {
216 self.policy_evaluator.is_some()
217 }
218
219 fn check_config_authorization(
224 &self,
225 query_info: &GraphQLQueryInfo,
226 start: Instant,
227 ) -> Option<ValidationResult> {
228 if !query_info.operation_type.is_read_only() {
230 let mutation_name = query_info.root_fields.first().cloned().unwrap_or_default();
231
232 if !self.config.blocked_mutations.is_empty()
233 && self.config.blocked_mutations.contains(&mutation_name)
234 {
235 return Some(ValidationResult::failure(
236 vec![PolicyViolation::new(
237 "code_mode",
238 "blocked_mutation",
239 &format!("Mutation '{}' is blocked for this server", mutation_name),
240 )
241 .with_suggestion("This mutation is in the blocklist and cannot be executed")],
242 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
243 ));
244 }
245
246 if !self.config.allowed_mutations.is_empty() {
247 if !self.config.allowed_mutations.contains(&mutation_name) {
248 return Some(ValidationResult::failure(
249 vec![PolicyViolation::new(
250 "code_mode",
251 "mutation_not_allowed",
252 &format!("Mutation '{}' is not in the allowlist", mutation_name),
253 )
254 .with_suggestion(&format!(
255 "Only these mutations are allowed: {}",
256 self.config
257 .allowed_mutations
258 .iter()
259 .cloned()
260 .collect::<Vec<_>>()
261 .join(", ")
262 ))],
263 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
264 ));
265 }
266 } else if !self.config.allow_mutations {
267 return Some(ValidationResult::failure(
268 vec![PolicyViolation::new(
269 "code_mode",
270 "allow_mutations",
271 "Mutations are not enabled for this server",
272 )
273 .with_suggestion("Only read-only queries are allowed")],
274 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
275 ));
276 }
277 }
278
279 if query_info.operation_type.is_read_only() {
281 let query_name = query_info.root_fields.first().cloned().unwrap_or_default();
282
283 if !self.config.blocked_queries.is_empty()
284 && self.config.blocked_queries.contains(&query_name)
285 {
286 return Some(ValidationResult::failure(
287 vec![PolicyViolation::new(
288 "code_mode",
289 "blocked_query",
290 &format!("Query '{}' is blocked for this server", query_name),
291 )
292 .with_suggestion("This query is in the blocklist and cannot be executed")],
293 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
294 ));
295 }
296
297 if !self.config.allowed_queries.is_empty()
298 && !self.config.allowed_queries.contains(&query_name)
299 {
300 return Some(ValidationResult::failure(
301 vec![PolicyViolation::new(
302 "code_mode",
303 "query_not_allowed",
304 &format!("Query '{}' is not in the allowlist", query_name),
305 )
306 .with_suggestion(&format!(
307 "Only these queries are allowed: {}",
308 self.config
309 .allowed_queries
310 .iter()
311 .cloned()
312 .collect::<Vec<_>>()
313 .join(", ")
314 ))],
315 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
316 ));
317 }
318 }
319
320 None
321 }
322
323 pub fn validate_graphql_query(
325 &self,
326 query: &str,
327 context: &ValidationContext,
328 ) -> Result<ValidationResult, ValidationError> {
329 let start = Instant::now();
330
331 if !self.config.enabled {
332 return Err(ValidationError::ConfigError(
333 "Code Mode is not enabled for this server".into(),
334 ));
335 }
336
337 if query.len() > self.config.max_query_length {
338 return Err(ValidationError::SecurityError {
339 message: format!(
340 "Query length {} exceeds maximum {}",
341 query.len(),
342 self.config.max_query_length
343 ),
344 issue: crate::types::SecurityIssueType::HighComplexity,
345 });
346 }
347
348 let query_info = self.graphql_validator.validate(query)?;
349
350 if let Some(failure) = self.check_config_authorization(&query_info, start) {
352 return Ok(failure);
353 }
354
355 self.complete_validation(query, &query_info, context, start)
356 }
357
358 pub async fn validate_graphql_query_async(
360 &self,
361 query: &str,
362 context: &ValidationContext,
363 ) -> Result<ValidationResult, ValidationError> {
364 let start = Instant::now();
365
366 if !self.config.enabled {
367 return Err(ValidationError::ConfigError(
368 "Code Mode is not enabled for this server".into(),
369 ));
370 }
371
372 if query.len() > self.config.max_query_length {
373 return Err(ValidationError::SecurityError {
374 message: format!(
375 "Query length {} exceeds maximum {}",
376 query.len(),
377 self.config.max_query_length
378 ),
379 issue: crate::types::SecurityIssueType::HighComplexity,
380 });
381 }
382
383 let query_info = self.graphql_validator.validate(query)?;
384
385 if let Some(ref evaluator) = self.policy_evaluator {
387 let operation_entity = OperationEntity::from_query_info(&query_info);
388 let server_config = self.config.to_server_config_entity();
389
390 let decision = evaluator
391 .evaluate_operation(&operation_entity, &server_config)
392 .await
393 .map_err(|e| {
394 ValidationError::InternalError(format!("Policy evaluation error: {}", e))
395 })?;
396
397 if !decision.allowed {
398 let violations: Vec<PolicyViolation> = decision
399 .determining_policies
400 .iter()
401 .map(|policy_id| {
402 PolicyViolation::new(
403 "policy",
404 policy_id.clone(),
405 "Policy denied the operation",
406 )
407 })
408 .collect();
409
410 return Ok(ValidationResult::failure(
411 violations,
412 self.build_metadata(&query_info, start.elapsed().as_millis() as u64),
413 ));
414 }
415 } else {
416 warn_no_policy_configured();
417 tracing::debug!(
418 target: "code_mode",
419 "Falling back to basic config checks (no policy evaluator configured)"
420 );
421 if let Some(failure) = self.check_config_authorization(&query_info, start) {
423 return Ok(failure);
424 }
425 }
426
427 self.complete_validation(query, &query_info, context, start)
428 }
429
430 fn complete_validation(
432 &self,
433 query: &str,
434 query_info: &GraphQLQueryInfo,
435 context: &ValidationContext,
436 start: Instant,
437 ) -> Result<ValidationResult, ValidationError> {
438 let security_analysis = self.graphql_validator.analyze_security(query_info);
439 let risk_level = security_analysis.assess_risk();
440
441 if security_analysis
442 .potential_issues
443 .iter()
444 .any(|i| i.is_critical())
445 {
446 let violations: Vec<PolicyViolation> = security_analysis
447 .potential_issues
448 .iter()
449 .filter(|i| i.is_critical())
450 .map(|i| {
451 PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
452 })
453 .collect();
454
455 return Ok(ValidationResult::failure(
456 violations,
457 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
458 ));
459 }
460
461 let explanation = self
462 .explanation_generator
463 .explain_graphql(query_info, &security_analysis);
464
465 let context_hash = context.context_hash();
466 let token = self.token_generator.generate(
467 query,
468 &context.user_id,
469 &context.session_id,
470 self.config.server_id(),
471 &context_hash,
472 risk_level,
473 self.config.token_ttl_seconds,
474 );
475
476 let token_string = token.encode().map_err(|e| {
477 ValidationError::InternalError(format!("Failed to encode token: {}", e))
478 })?;
479
480 let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
481 let mutation_name = query_info.operation_name.as_deref();
482 let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
483 let action = UnifiedAction::resolve(
484 inferred_action,
485 &self.config.action_tags,
486 query_info.operation_name.as_deref().unwrap_or(""),
487 );
488
489 let metadata = ValidationMetadata {
490 is_read_only: query_info.operation_type.is_read_only(),
491 estimated_rows: security_analysis.estimated_rows,
492 accessed_types: security_analysis.tables_accessed.iter().cloned().collect(),
493 accessed_fields: security_analysis.fields_accessed.iter().cloned().collect(),
494 has_aggregation: security_analysis.has_aggregation,
495 code_type: Some(self.graphql_validator.to_code_type(query_info)),
496 action: Some(action),
497 validation_time_ms: start.elapsed().as_millis() as u64,
498 };
499
500 let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
501
502 for issue in &security_analysis.potential_issues {
503 if !issue.is_critical() {
504 result.warnings.push(issue.message.clone());
505 }
506 }
507
508 Ok(result)
509 }
510
511 fn build_metadata(
513 &self,
514 query_info: &GraphQLQueryInfo,
515 validation_time_ms: u64,
516 ) -> ValidationMetadata {
517 let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
518 let mutation_name = query_info.operation_name.as_deref();
519 let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
520 let action = UnifiedAction::resolve(
521 inferred_action,
522 &self.config.action_tags,
523 query_info.operation_name.as_deref().unwrap_or(""),
524 );
525
526 ValidationMetadata {
527 is_read_only: query_info.operation_type.is_read_only(),
528 estimated_rows: None,
529 accessed_types: query_info.types_accessed.iter().cloned().collect(),
530 accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
531 has_aggregation: false,
532 code_type: Some(self.graphql_validator.to_code_type(query_info)),
533 action: Some(action),
534 validation_time_ms,
535 }
536 }
537
538 #[cfg(feature = "openapi-code-mode")]
540 pub fn validate_javascript_code(
541 &self,
542 code: &str,
543 context: &ValidationContext,
544 ) -> Result<ValidationResult, ValidationError> {
545 let start = Instant::now();
546
547 if !self.config.enabled {
548 return Err(ValidationError::ConfigError(
549 "Code Mode is not enabled for this server".into(),
550 ));
551 }
552
553 if code.len() > self.config.max_query_length {
554 return Err(ValidationError::SecurityError {
555 message: format!(
556 "Code length {} exceeds maximum {}",
557 code.len(),
558 self.config.max_query_length
559 ),
560 issue: crate::types::SecurityIssueType::HighComplexity,
561 });
562 }
563
564 let code_info = self.javascript_validator.validate(code)?;
565
566 if !code_info.is_read_only {
567 for method in &code_info.methods_used {
568 if !self.config.openapi_blocked_writes.is_empty()
569 && self.config.openapi_blocked_writes.contains(method)
570 {
571 return Ok(ValidationResult::failure(
572 vec![PolicyViolation::new(
573 "code_mode",
574 "blocked_method",
575 &format!("HTTP method '{}' is blocked for this server", method),
576 )
577 .with_suggestion("This method is in the blocklist and cannot be used")],
578 self.build_js_metadata(&code_info, start.elapsed().as_millis() as u64),
579 ));
580 }
581 }
582
583 if !self.config.openapi_allowed_writes.is_empty() {
584 tracing::debug!(
585 target: "code_mode",
586 "Skipping method-level check - policy evaluator will check operation allowlist ({} entries)",
587 self.config.openapi_allowed_writes.len()
588 );
589 } else if !self.config.openapi_allow_writes {
590 return Ok(ValidationResult::failure(
591 vec![PolicyViolation::new(
592 "code_mode",
593 "allow_mutations",
594 "Write HTTP methods (POST, PUT, DELETE, PATCH) are not enabled for this server",
595 )
596 .with_suggestion("Only read-only methods (GET, HEAD, OPTIONS) are allowed. Contact your administrator to enable write operations.")],
597 self.build_js_metadata(&code_info, start.elapsed().as_millis() as u64),
598 ));
599 }
600 }
601
602 self.complete_js_validation(code, &code_info, context, start)
603 }
604
605 #[cfg(feature = "openapi-code-mode")]
607 fn complete_js_validation(
608 &self,
609 code: &str,
610 code_info: &JavaScriptCodeInfo,
611 context: &ValidationContext,
612 start: Instant,
613 ) -> Result<ValidationResult, ValidationError> {
614 let security_analysis = self.javascript_validator.analyze_security(code_info);
615 let risk_level = security_analysis.assess_risk();
616
617 if security_analysis
618 .potential_issues
619 .iter()
620 .any(|i| i.is_critical())
621 {
622 let violations: Vec<PolicyViolation> = security_analysis
623 .potential_issues
624 .iter()
625 .filter(|i| i.is_critical())
626 .map(|i| {
627 PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
628 })
629 .collect();
630
631 return Ok(ValidationResult::failure(
632 violations,
633 self.build_js_metadata(code_info, start.elapsed().as_millis() as u64),
634 ));
635 }
636
637 let explanation = self.generate_js_explanation(code_info, &security_analysis);
638
639 let context_hash = context.context_hash();
640 let token = self.token_generator.generate(
641 code,
642 &context.user_id,
643 &context.session_id,
644 self.config.server_id(),
645 &context_hash,
646 risk_level,
647 self.config.token_ttl_seconds,
648 );
649
650 let token_string = token.encode().map_err(|e| {
651 ValidationError::InternalError(format!("Failed to encode token: {}", e))
652 })?;
653
654 let metadata = self.build_js_metadata(code_info, start.elapsed().as_millis() as u64);
655
656 let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
657
658 for issue in &security_analysis.potential_issues {
659 if !issue.is_critical() {
660 result.warnings.push(issue.message.clone());
661 }
662 }
663
664 Ok(result)
665 }
666
667 #[cfg(feature = "openapi-code-mode")]
669 fn build_js_metadata(
670 &self,
671 code_info: &JavaScriptCodeInfo,
672 validation_time_ms: u64,
673 ) -> ValidationMetadata {
674 let action = if !code_info.api_calls.is_empty() {
675 let mut max_action = UnifiedAction::Read;
676 for call in &code_info.api_calls {
677 let method_str = format!("{:?}", call.method);
678 let inferred = UnifiedAction::from_http_method(&method_str);
679 match (&max_action, &inferred) {
680 (UnifiedAction::Read, _) => max_action = inferred,
681 (UnifiedAction::Write, UnifiedAction::Delete | UnifiedAction::Admin) => {
682 max_action = inferred
683 },
684 (UnifiedAction::Delete, UnifiedAction::Admin) => max_action = inferred,
685 _ => {},
686 }
687 }
688 Some(max_action)
689 } else if code_info.is_read_only {
690 Some(UnifiedAction::Read)
691 } else {
692 Some(UnifiedAction::Write)
693 };
694
695 ValidationMetadata {
696 is_read_only: code_info.is_read_only,
697 estimated_rows: None,
698 accessed_types: code_info.endpoints_accessed.iter().cloned().collect(),
699 accessed_fields: code_info.methods_used.iter().cloned().collect(),
700 has_aggregation: false,
701 code_type: Some(self.javascript_validator.to_code_type(code_info)),
702 action,
703 validation_time_ms,
704 }
705 }
706
707 #[cfg(feature = "openapi-code-mode")]
709 fn generate_js_explanation(
710 &self,
711 code_info: &JavaScriptCodeInfo,
712 security_analysis: &crate::types::SecurityAnalysis,
713 ) -> String {
714 let mut parts = Vec::new();
715
716 if code_info.is_read_only {
717 parts.push("This code will perform read-only API requests.".to_string());
718 } else {
719 parts.push("This code will perform API requests that may modify data.".to_string());
720 }
721
722 if !code_info.api_calls.is_empty() {
723 let call_descriptions: Vec<String> = code_info
724 .api_calls
725 .iter()
726 .map(|call| format!("{:?} {}", call.method, call.path))
727 .collect();
728
729 if call_descriptions.len() <= 3 {
730 parts.push(format!("API calls: {}", call_descriptions.join(", ")));
731 } else {
732 parts.push(format!(
733 "API calls: {} and {} more",
734 call_descriptions[..2].join(", "),
735 call_descriptions.len() - 2
736 ));
737 }
738 }
739
740 if code_info.loop_count > 0 {
741 if code_info.all_loops_bounded {
742 parts.push(format!(
743 "Contains {} bounded loop(s).",
744 code_info.loop_count
745 ));
746 } else {
747 parts.push(format!(
748 "Contains {} loop(s) - ensure they are properly bounded.",
749 code_info.loop_count
750 ));
751 }
752 }
753
754 let risk = security_analysis.assess_risk();
755 parts.push(format!("Risk: {}", risk));
756
757 parts.join(" ")
758 }
759
760 pub fn should_auto_approve(&self, result: &ValidationResult) -> bool {
762 result.is_valid && self.config.should_auto_approve(result.risk_level)
763 }
764
765 pub fn config(&self) -> &CodeModeConfig {
767 &self.config
768 }
769
770 pub fn token_generator(&self) -> &T {
772 &self.token_generator
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::types::RiskLevel;
780
781 fn test_pipeline() -> ValidationPipeline {
782 ValidationPipeline::new(CodeModeConfig::enabled(), b"test-secret-key!".to_vec()).unwrap()
783 }
784
785 fn test_context() -> ValidationContext {
786 ValidationContext::new("user-123", "session-456", "schema-hash", "perms-hash")
787 }
788
789 #[test]
790 fn test_simple_query_validation() {
791 let pipeline = test_pipeline();
792 let ctx = test_context();
793
794 let result = pipeline
795 .validate_graphql_query("query { users { id name } }", &ctx)
796 .unwrap();
797
798 assert!(result.is_valid);
799 assert!(result.approval_token.is_some());
800 assert_eq!(result.risk_level, RiskLevel::Low);
801 assert!(result.explanation.contains("read"));
802 }
803
804 #[test]
805 fn test_mutation_blocked() {
806 let mut config = CodeModeConfig::enabled();
807 config.allow_mutations = false;
808
809 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
810 let ctx = test_context();
811
812 let result = pipeline
813 .validate_graphql_query("mutation { createUser(name: \"test\") { id } }", &ctx)
814 .unwrap();
815
816 assert!(!result.is_valid);
817 assert!(result
818 .violations
819 .iter()
820 .any(|v| v.rule == "allow_mutations"));
821 }
822
823 #[test]
824 fn test_disabled_code_mode() {
825 let config = CodeModeConfig::default();
826 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
827 let ctx = test_context();
828
829 let result = pipeline.validate_graphql_query("query { users { id } }", &ctx);
830
831 assert!(matches!(result, Err(ValidationError::ConfigError(_))));
832 }
833
834 #[test]
835 fn test_auto_approve_low_risk() {
836 let pipeline = test_pipeline();
837 let ctx = test_context();
838
839 let result = pipeline
840 .validate_graphql_query("query { users { id } }", &ctx)
841 .unwrap();
842
843 assert!(pipeline.should_auto_approve(&result));
844 }
845
846 #[test]
847 fn test_context_hash() {
848 let ctx = test_context();
849 let hash1 = ctx.context_hash();
850
851 let ctx2 =
852 ValidationContext::new("user-123", "session-456", "different-schema", "perms-hash");
853 let hash2 = ctx2.context_hash();
854
855 assert_ne!(hash1, hash2);
856 }
857
858 #[test]
859 fn test_blocked_query_rejected() {
860 let mut config = CodeModeConfig::enabled();
861 config.blocked_queries.insert("users".to_string());
862
863 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
864 let ctx = test_context();
865
866 let result = pipeline
867 .validate_graphql_query("query { users { id } }", &ctx)
868 .unwrap();
869
870 assert!(!result.is_valid);
871 assert!(result.violations.iter().any(|v| v.rule == "blocked_query"));
872 }
873
874 #[test]
875 fn test_allowed_queries_enforced() {
876 let mut config = CodeModeConfig::enabled();
877 config.allowed_queries.insert("orders".to_string());
878
879 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
880 let ctx = test_context();
881
882 let result = pipeline
884 .validate_graphql_query("query { users { id } }", &ctx)
885 .unwrap();
886
887 assert!(!result.is_valid);
888 assert!(result
889 .violations
890 .iter()
891 .any(|v| v.rule == "query_not_allowed"));
892 }
893}