1#[cfg(feature = "openapi-code-mode")]
7use crate::config::OperationRegistry;
8use crate::graphql::GraphQLQueryInfo;
9use std::collections::HashSet;
10
11#[derive(Debug, Clone)]
17pub struct ServerConfigEntity {
18 pub server_id: String,
20
21 pub server_type: String,
23
24 pub allow_write: bool,
26
27 pub allow_delete: bool,
29
30 pub allow_admin: bool,
32
33 pub allowed_operations: HashSet<String>,
35
36 pub blocked_operations: HashSet<String>,
38
39 pub max_depth: u32,
41
42 pub max_field_count: u32,
44
45 pub max_cost: u32,
47
48 pub max_api_calls: u32,
50
51 pub blocked_fields: HashSet<String>,
53
54 pub allowed_sensitive_categories: HashSet<String>,
56}
57
58impl Default for ServerConfigEntity {
59 fn default() -> Self {
60 Self {
61 server_id: "unknown".to_string(),
62 server_type: "graphql".to_string(),
63 allow_write: false,
64 allow_delete: false,
65 allow_admin: false,
66 allowed_operations: HashSet::new(),
67 blocked_operations: HashSet::new(),
68 max_depth: 10,
69 max_field_count: 100,
70 max_cost: 1000,
71 max_api_calls: 50,
72 blocked_fields: HashSet::new(),
73 allowed_sensitive_categories: HashSet::new(),
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct OperationEntity {
81 pub id: String,
83
84 pub operation_type: String,
86
87 pub operation_name: String,
89
90 pub root_fields: HashSet<String>,
92
93 pub accessed_types: HashSet<String>,
95
96 pub accessed_fields: HashSet<String>,
98
99 pub depth: u32,
101
102 pub field_count: u32,
104
105 pub estimated_cost: u32,
107
108 pub has_introspection: bool,
110
111 pub accesses_sensitive_data: bool,
113
114 pub sensitive_categories: HashSet<String>,
116}
117
118impl OperationEntity {
119 pub fn from_query_info(query_info: &GraphQLQueryInfo) -> Self {
121 use crate::graphql::GraphQLOperationType;
122
123 let operation_type = match query_info.operation_type {
124 GraphQLOperationType::Query => "query",
125 GraphQLOperationType::Mutation => "mutation",
126 GraphQLOperationType::Subscription => "subscription",
127 };
128
129 Self {
130 id: query_info
131 .operation_name
132 .clone()
133 .unwrap_or_else(|| "anonymous".to_string()),
134 operation_type: operation_type.to_string(),
135 operation_name: query_info.operation_name.clone().unwrap_or_default(),
136 root_fields: query_info.root_fields.iter().cloned().collect(),
137 accessed_types: query_info.types_accessed.iter().cloned().collect(),
138 accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
139 depth: query_info.max_depth as u32,
140 field_count: query_info.fields_accessed.len() as u32,
141 estimated_cost: query_info.fields_accessed.len() as u32,
142 has_introspection: query_info.has_introspection,
143 accesses_sensitive_data: false,
144 sensitive_categories: HashSet::new(),
145 }
146 }
147}
148
149#[derive(Debug, Clone)]
151pub struct AuthorizationDecision {
152 pub allowed: bool,
154
155 pub determining_policies: Vec<String>,
157
158 pub errors: Vec<String>,
160}
161
162#[cfg(feature = "openapi-code-mode")]
167#[derive(Debug, Clone)]
168pub struct ScriptEntity {
169 pub id: String,
171
172 pub script_type: String,
174
175 pub has_writes: bool,
177
178 pub has_deletes: bool,
180
181 pub total_api_calls: u32,
183
184 pub read_calls: u32,
186
187 pub write_calls: u32,
189
190 pub delete_calls: u32,
192
193 pub accessed_paths: HashSet<String>,
195
196 pub accessed_methods: HashSet<String>,
198
199 pub path_patterns: HashSet<String>,
201
202 pub called_operations: HashSet<String>,
204
205 pub loop_iterations: u32,
207
208 pub nesting_depth: u32,
210
211 pub script_length: u32,
213
214 pub accesses_sensitive_path: bool,
216
217 pub has_unbounded_loop: bool,
219
220 pub has_dynamic_path: bool,
222
223 pub has_output_declaration: bool,
225
226 pub output_fields: HashSet<String>,
228
229 pub has_spread_in_output: bool,
231}
232
233#[cfg(feature = "openapi-code-mode")]
234impl ScriptEntity {
235 pub fn from_javascript_info(
237 info: &crate::javascript::JavaScriptCodeInfo,
238 sensitive_patterns: &[String],
239 registry: Option<&OperationRegistry>,
240 ) -> Self {
241 use crate::javascript::HttpMethod;
242
243 let mut accessed_paths = HashSet::new();
244 let mut accessed_methods = HashSet::new();
245 let mut path_patterns = HashSet::new();
246 let mut called_operations = HashSet::new();
247 let mut read_calls = 0u32;
248 let mut write_calls = 0u32;
249 let mut delete_calls = 0u32;
250 let mut has_dynamic_path = false;
251 let mut accesses_sensitive_path = false;
252
253 for api_call in &info.api_calls {
254 accessed_paths.insert(api_call.path.clone());
255 let method_str = format!("{:?}", api_call.method).to_uppercase();
256 accessed_methods.insert(method_str.clone());
257
258 let pattern = normalize_path_to_pattern(&api_call.path);
260 path_patterns.insert(pattern.clone());
261
262 let op_id = registry
265 .and_then(|r| r.lookup(&api_call.path))
266 .map(|id| id.to_string())
267 .unwrap_or_else(|| format!("{}:{}", method_str, pattern));
268 called_operations.insert(op_id);
269
270 let call_category = registry.and_then(|r| r.lookup_category(&api_call.path));
273 match call_category {
274 Some("read") => read_calls += 1,
275 Some("delete") => delete_calls += 1,
276 Some("write" | "admin") => write_calls += 1,
277 Some(_) => write_calls += 1,
278 None => match api_call.method {
279 HttpMethod::Get | HttpMethod::Head | HttpMethod::Options => read_calls += 1,
280 HttpMethod::Delete => delete_calls += 1,
281 _ => write_calls += 1,
282 },
283 }
284
285 if api_call.is_dynamic_path {
287 has_dynamic_path = true;
288 }
289
290 let path_lower = api_call.path.to_lowercase();
292 for pattern in sensitive_patterns {
293 if path_lower.contains(&pattern.to_lowercase()) {
294 accesses_sensitive_path = true;
295 break;
296 }
297 }
298 }
299
300 let has_writes = write_calls > 0 || delete_calls > 0;
302 let has_reads = read_calls > 0;
303 let script_type = match (has_reads, has_writes) {
304 (true, false) => "read_only",
305 (false, true) => "write_only",
306 (true, true) => "mixed",
307 (false, false) => "empty",
308 };
309
310 Self {
311 id: info
312 .api_calls
313 .first()
314 .map(|c| format!("{}:{}", format!("{:?}", c.method).to_uppercase(), c.path))
315 .unwrap_or_else(|| "script".to_string()),
316 script_type: script_type.to_string(),
317 has_writes,
318 has_deletes: delete_calls > 0,
319 total_api_calls: info.api_calls.len() as u32,
320 read_calls,
321 write_calls,
322 delete_calls,
323 accessed_paths,
324 accessed_methods,
325 path_patterns,
326 called_operations,
327 loop_iterations: 0,
328 nesting_depth: info.max_depth as u32,
329 script_length: 0,
330 accesses_sensitive_path,
331 has_unbounded_loop: !info.all_loops_bounded && info.loop_count > 0,
332 has_dynamic_path,
333 has_output_declaration: info.output_declaration.has_declaration,
334 output_fields: info.output_declaration.declared_fields.clone(),
335 has_spread_in_output: info.output_declaration.has_spread_risk
336 || info.has_output_spread_risk,
337 }
338 }
339
340 pub fn action(&self) -> &'static str {
342 match self.script_type.as_str() {
343 "read_only" | "empty" => "Read",
344 "write_only" | "mixed" => {
345 if self.has_deletes {
346 "Delete"
347 } else {
348 "Write"
349 }
350 },
351 _ => "Read",
352 }
353 }
354}
355
356#[cfg(feature = "openapi-code-mode")]
358fn is_uuid_like(segment: &str) -> bool {
359 if segment.len() != 36 {
360 return false;
361 }
362 let parts: Vec<&str> = segment.split('-').collect();
363 matches!(parts.as_slice(), [a, b, c, d, e]
364 if a.len() == 8 && b.len() == 4 && c.len() == 4
365 && d.len() == 4 && e.len() == 12
366 && segment.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'))
367}
368
369#[cfg(feature = "openapi-code-mode")]
371pub fn normalize_path_to_pattern(path: &str) -> String {
372 path.split('/')
373 .map(|segment| {
374 if segment.chars().all(|c| c.is_ascii_digit()) || is_uuid_like(segment) {
375 "*"
376 } else {
377 segment
378 }
379 })
380 .collect::<Vec<_>>()
381 .join("/")
382}
383
384#[cfg(feature = "openapi-code-mode")]
386pub fn normalize_operation_format(op: &str) -> String {
387 let trimmed = op.trim();
388
389 let (method, path) = if let Some(idx) = trimmed.find(':') {
390 let potential_path = trimmed[idx + 1..].trim();
391 if potential_path.starts_with('/') {
392 let method = trimmed[..idx].trim();
393 (method, potential_path)
394 } else {
395 return trimmed.to_string();
396 }
397 } else if let Some(idx) = trimmed.find(' ') {
398 let method = trimmed[..idx].trim();
399 let path = trimmed[idx + 1..].trim();
400 (method, path)
401 } else {
402 return trimmed.to_string();
403 };
404
405 let method_upper = method.to_uppercase();
406
407 let normalized_path = path
408 .split('/')
409 .map(|segment| {
410 if segment.starts_with('{') && segment.ends_with('}') {
411 "*"
412 } else if segment.starts_with(':') {
413 "*"
414 } else if segment.chars().all(|c| c.is_ascii_digit()) {
415 "*"
416 } else if is_uuid_like(segment) {
417 "*"
418 } else {
419 segment
420 }
421 })
422 .collect::<Vec<_>>()
423 .join("/");
424
425 format!("{}:{}", method_upper, normalized_path)
426}
427
428#[cfg(feature = "openapi-code-mode")]
430#[derive(Debug, Clone)]
431pub struct OpenAPIServerEntity {
432 pub server_id: String,
433 pub server_type: String,
434
435 pub allow_write: bool,
437 pub allow_delete: bool,
438 pub allow_admin: bool,
439
440 pub write_mode: String,
442
443 pub max_depth: u32,
445 pub max_cost: u32,
446 pub max_api_calls: u32,
447
448 pub max_loop_iterations: u32,
450 pub max_script_length: u32,
451 pub max_nesting_depth: u32,
452 pub execution_timeout_seconds: u32,
453
454 pub allowed_operations: HashSet<String>,
456 pub blocked_operations: HashSet<String>,
457
458 pub allowed_methods: HashSet<String>,
460 pub blocked_methods: HashSet<String>,
461 pub allowed_path_patterns: HashSet<String>,
462 pub blocked_path_patterns: HashSet<String>,
463 pub sensitive_path_patterns: HashSet<String>,
464
465 pub auto_approve_read_only: bool,
467 pub max_api_calls_for_auto_approve: u32,
468
469 pub internal_blocked_fields: HashSet<String>,
471 pub output_blocked_fields: HashSet<String>,
472 pub require_output_declaration: bool,
473}
474
475#[cfg(feature = "openapi-code-mode")]
476impl Default for OpenAPIServerEntity {
477 fn default() -> Self {
478 Self {
479 server_id: "unknown".to_string(),
480 server_type: "openapi".to_string(),
481 allow_write: false,
482 allow_delete: false,
483 allow_admin: false,
484 write_mode: "deny_all".to_string(),
485 max_depth: 10,
486 max_cost: 1000,
487 max_api_calls: 50,
488 max_loop_iterations: 100,
489 max_script_length: 10000,
490 max_nesting_depth: 10,
491 execution_timeout_seconds: 30,
492 allowed_operations: HashSet::new(),
493 blocked_operations: HashSet::new(),
494 allowed_methods: HashSet::new(),
495 blocked_methods: HashSet::new(),
496 allowed_path_patterns: HashSet::new(),
497 blocked_path_patterns: ["/admin".into(), "/internal".into()].into_iter().collect(),
498 sensitive_path_patterns: ["/admin".into(), "/internal".into(), "/debug".into()]
499 .into_iter()
500 .collect(),
501 auto_approve_read_only: true,
502 max_api_calls_for_auto_approve: 10,
503 internal_blocked_fields: HashSet::new(),
504 output_blocked_fields: HashSet::new(),
505 require_output_declaration: false,
506 }
507 }
508}
509
510pub fn get_code_mode_schema_json() -> serde_json::Value {
514 let applies_to = serde_json::json!({
515 "principalTypes": ["Operation"],
516 "resourceTypes": ["Server"],
517 "context": {
518 "type": "Record",
519 "attributes": {
520 "serverId": { "type": "String", "required": true },
521 "serverType": { "type": "String", "required": true },
522 "userId": { "type": "String", "required": false },
523 "sessionId": { "type": "String", "required": false }
524 }
525 }
526 });
527
528 serde_json::json!({
529 "CodeMode": {
530 "entityTypes": {
531 "Operation": {
532 "shape": {
533 "type": "Record",
534 "attributes": {
535 "operationType": { "type": "String", "required": true },
536 "operationName": { "type": "String", "required": true },
537 "rootFields": { "type": "Set", "element": { "type": "String" } },
538 "accessedTypes": { "type": "Set", "element": { "type": "String" } },
539 "accessedFields": { "type": "Set", "element": { "type": "String" } },
540 "depth": { "type": "Long", "required": true },
541 "fieldCount": { "type": "Long", "required": true },
542 "estimatedCost": { "type": "Long", "required": true },
543 "hasIntrospection": { "type": "Boolean", "required": true },
544 "accessesSensitiveData": { "type": "Boolean", "required": true },
545 "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
546 }
547 }
548 },
549 "Server": {
550 "shape": {
551 "type": "Record",
552 "attributes": {
553 "serverId": { "type": "String", "required": true },
554 "serverType": { "type": "String", "required": true },
555 "maxDepth": { "type": "Long", "required": true },
556 "maxFieldCount": { "type": "Long", "required": true },
557 "maxCost": { "type": "Long", "required": true },
558 "maxApiCalls": { "type": "Long", "required": true },
559 "allowWrite": { "type": "Boolean", "required": true },
560 "allowDelete": { "type": "Boolean", "required": true },
561 "allowAdmin": { "type": "Boolean", "required": true },
562 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
563 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
564 "blockedFields": { "type": "Set", "element": { "type": "String" } }
565 }
566 }
567 }
568 },
569 "actions": {
570 "Read": { "appliesTo": applies_to },
571 "Write": { "appliesTo": applies_to },
572 "Delete": { "appliesTo": applies_to },
573 "Admin": { "appliesTo": applies_to }
574 }
575 }
576 })
577}
578
579#[cfg(feature = "openapi-code-mode")]
581pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
582 let applies_to = serde_json::json!({
583 "principalTypes": ["Script"],
584 "resourceTypes": ["Server"],
585 "context": {
586 "type": "Record",
587 "attributes": {
588 "serverId": { "type": "String", "required": true },
589 "serverType": { "type": "String", "required": true },
590 "userId": { "type": "String", "required": false },
591 "sessionId": { "type": "String", "required": false }
592 }
593 }
594 });
595
596 serde_json::json!({
597 "CodeMode": {
598 "entityTypes": {
599 "Script": {
600 "shape": {
601 "type": "Record",
602 "attributes": {
603 "scriptType": { "type": "String", "required": true },
604 "hasWrites": { "type": "Boolean", "required": true },
605 "hasDeletes": { "type": "Boolean", "required": true },
606 "totalApiCalls": { "type": "Long", "required": true },
607 "readCalls": { "type": "Long", "required": true },
608 "writeCalls": { "type": "Long", "required": true },
609 "deleteCalls": { "type": "Long", "required": true },
610 "accessedPaths": { "type": "Set", "element": { "type": "String" } },
611 "accessedMethods": { "type": "Set", "element": { "type": "String" } },
612 "pathPatterns": { "type": "Set", "element": { "type": "String" } },
613 "calledOperations": { "type": "Set", "element": { "type": "String" } },
614 "loopIterations": { "type": "Long", "required": true },
615 "nestingDepth": { "type": "Long", "required": true },
616 "scriptLength": { "type": "Long", "required": true },
617 "accessesSensitivePath": { "type": "Boolean", "required": true },
618 "hasUnboundedLoop": { "type": "Boolean", "required": true },
619 "hasDynamicPath": { "type": "Boolean", "required": true },
620 "outputFields": { "type": "Set", "element": { "type": "String" } },
621 "hasOutputDeclaration": { "type": "Boolean", "required": true },
622 "hasSpreadInOutput": { "type": "Boolean", "required": true }
623 }
624 }
625 },
626 "Server": {
627 "shape": {
628 "type": "Record",
629 "attributes": {
630 "serverId": { "type": "String", "required": true },
631 "serverType": { "type": "String", "required": true },
632 "writeMode": { "type": "String", "required": true },
633 "maxDepth": { "type": "Long", "required": true },
634 "maxCost": { "type": "Long", "required": true },
635 "maxApiCalls": { "type": "Long", "required": true },
636 "allowWrite": { "type": "Boolean", "required": true },
637 "allowDelete": { "type": "Boolean", "required": true },
638 "allowAdmin": { "type": "Boolean", "required": true },
639 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
640 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
641 "blockedFields": { "type": "Set", "element": { "type": "String" } },
642 "maxLoopIterations": { "type": "Long", "required": true },
643 "maxScriptLength": { "type": "Long", "required": true },
644 "maxNestingDepth": { "type": "Long", "required": true },
645 "executionTimeoutSeconds": { "type": "Long", "required": true },
646 "allowedMethods": { "type": "Set", "element": { "type": "String" } },
647 "blockedMethods": { "type": "Set", "element": { "type": "String" } },
648 "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
649 "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
650 "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
651 "autoApproveReadOnly": { "type": "Boolean", "required": true },
652 "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
653 "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
654 "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
655 "requireOutputDeclaration": { "type": "Boolean", "required": true }
656 }
657 }
658 }
659 },
660 "actions": {
661 "Read": { "appliesTo": applies_to },
662 "Write": { "appliesTo": applies_to },
663 "Delete": { "appliesTo": applies_to },
664 "Admin": { "appliesTo": applies_to }
665 }
666 }
667 })
668}
669
670#[cfg(feature = "openapi-code-mode")]
672pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
673 vec![
674 (
675 "permit_reads",
676 "Permit all read operations (GET scripts)",
677 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
678 ),
679 (
680 "permit_writes",
681 "Permit write operations (when enabled)",
682 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
683 ),
684 (
685 "permit_deletes",
686 "Permit delete operations (when enabled)",
687 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
688 ),
689 (
690 "forbid_sensitive_paths",
691 "Block scripts accessing sensitive paths",
692 r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
693 ),
694 (
695 "forbid_unbounded_loops",
696 "Block scripts with unbounded loops",
697 r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
698 ),
699 (
700 "forbid_excessive_api_calls",
701 "Enforce API call limit",
702 r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
703 ),
704 (
705 "forbid_excessive_nesting",
706 "Enforce nesting depth limit",
707 r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
708 ),
709 (
710 "forbid_output_blocked_fields",
711 "Block scripts that return output-blocked fields",
712 r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
713 ),
714 (
715 "forbid_spread_without_declaration",
716 "Block scripts with spread in output when output declaration is required",
717 r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
718 ),
719 (
720 "forbid_missing_output_declaration",
721 "Block scripts without output declaration when required",
722 r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
723 ),
724 ]
725}
726
727pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
729 vec![
730 (
731 "permit_reads",
732 "Permit all read operations (queries)",
733 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
734 ),
735 (
736 "permit_writes",
737 "Permit write operations (when enabled)",
738 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
739 ),
740 (
741 "permit_deletes",
742 "Permit delete operations (when enabled)",
743 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
744 ),
745 (
746 "permit_admin",
747 "Permit admin operations (when enabled)",
748 r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
749 ),
750 (
751 "forbid_blocked_operations",
752 "Block operations in blocklist",
753 r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
754 ),
755 (
756 "forbid_blocked_fields",
757 "Block access to blocked fields",
758 r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
759 ),
760 (
761 "forbid_excessive_depth",
762 "Enforce maximum query depth",
763 r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
764 ),
765 (
766 "forbid_excessive_cost",
767 "Enforce maximum query cost",
768 r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
769 ),
770 ]
771}
772
773#[cfg(all(test, feature = "openapi-code-mode"))]
774mod tests {
775 use super::*;
776 use crate::config::{OperationEntry, OperationRegistry};
777 use crate::javascript::{ApiCall, HttpMethod, JavaScriptCodeInfo};
778
779 fn make_api_call(method: HttpMethod, path: &str) -> ApiCall {
780 ApiCall {
781 method,
782 path: path.to_string(),
783 is_dynamic_path: false,
784 line: 1,
785 column: 0,
786 }
787 }
788
789 fn make_info(calls: Vec<ApiCall>) -> JavaScriptCodeInfo {
790 JavaScriptCodeInfo {
791 api_calls: calls,
792 ..Default::default()
793 }
794 }
795
796 fn make_registry(entries: &[(&str, &str, &str)]) -> OperationRegistry {
797 let entries: Vec<OperationEntry> = entries
798 .iter()
799 .map(|(id, category, path)| OperationEntry {
800 id: id.to_string(),
801 category: category.to_string(),
802 description: String::new(),
803 path: Some(path.to_string()),
804 })
805 .collect();
806 OperationRegistry::from_entries(&entries)
807 }
808
809 #[test]
810 fn test_category_read_overrides_post_method() {
811 let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
812 let info = make_info(vec![make_api_call(HttpMethod::Post, "/getCostAnomalies")]);
813
814 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
815
816 assert_eq!(entity.read_calls, 1);
817 assert_eq!(entity.write_calls, 0);
818 assert_eq!(entity.script_type, "read_only");
819 assert_eq!(entity.action(), "Read");
820 }
821
822 #[test]
823 fn test_category_write_overrides_get_method() {
824 let registry = make_registry(&[("triggerExport", "write", "/triggerExport")]);
825 let info = make_info(vec![make_api_call(HttpMethod::Get, "/triggerExport")]);
826
827 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
828
829 assert_eq!(entity.write_calls, 1);
830 assert_eq!(entity.read_calls, 0);
831 assert_eq!(entity.script_type, "write_only");
832 assert_eq!(entity.action(), "Write");
833 }
834
835 #[test]
836 fn test_category_delete_routes_correctly() {
837 let registry = make_registry(&[("deleteReservation", "delete", "/deleteReservation")]);
838 let info = make_info(vec![make_api_call(HttpMethod::Post, "/deleteReservation")]);
839
840 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
841
842 assert_eq!(entity.delete_calls, 1);
843 assert_eq!(entity.has_deletes, true);
844 assert_eq!(entity.action(), "Delete");
845 }
846
847 #[test]
848 fn test_no_registry_falls_back_to_http_method() {
849 let info = make_info(vec![
850 make_api_call(HttpMethod::Get, "/getCostAnomalies"),
851 make_api_call(HttpMethod::Post, "/updateBudget"),
852 ]);
853
854 let entity = ScriptEntity::from_javascript_info(&info, &[], None);
855
856 assert_eq!(entity.read_calls, 1);
857 assert_eq!(entity.write_calls, 1);
858 assert_eq!(entity.script_type, "mixed");
859 assert_eq!(entity.action(), "Write");
860 }
861
862 #[test]
863 fn test_unregistered_path_falls_back_to_http_method() {
864 let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
865 let info = make_info(vec![make_api_call(HttpMethod::Post, "/unknownEndpoint")]);
866
867 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
868
869 assert_eq!(entity.write_calls, 1);
871 assert_eq!(entity.read_calls, 0);
872 assert_eq!(entity.script_type, "write_only");
873 }
874
875 #[test]
876 fn test_mixed_categories_produce_mixed_script() {
877 let registry = make_registry(&[
878 ("getCostAnomalies", "read", "/getCostAnomalies"),
879 ("updateBudget", "write", "/updateBudget"),
880 ]);
881 let info = make_info(vec![
882 make_api_call(HttpMethod::Post, "/getCostAnomalies"),
883 make_api_call(HttpMethod::Post, "/updateBudget"),
884 ]);
885
886 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
887
888 assert_eq!(entity.read_calls, 1);
889 assert_eq!(entity.write_calls, 1);
890 assert_eq!(entity.script_type, "mixed");
891 assert_eq!(entity.action(), "Write");
892 }
893
894 #[test]
895 fn test_empty_category_falls_back_to_http_method() {
896 let registry = make_registry(&[("legacyOp", "", "/legacyOp")]);
898 let info = make_info(vec![make_api_call(HttpMethod::Post, "/legacyOp")]);
899
900 let entity = ScriptEntity::from_javascript_info(&info, &[], Some(®istry));
901
902 assert_eq!(entity.write_calls, 1);
904 assert_eq!(entity.script_type, "write_only");
905 }
906}