Skip to main content

mockforge_bench/
crud_flow.rs

1//! CRUD Flow support for load testing
2//!
3//! This module provides functionality to automatically detect and configure
4//! CRUD (Create, Read, Update, Delete) flows from OpenAPI specifications,
5//! enabling sequential testing with response chaining.
6
7use crate::error::{BenchError, Result};
8use crate::spec_parser::ApiOperation;
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13/// Field extraction configuration - supports field extraction, aliased extraction, and full body extraction
14#[derive(Debug, Clone, Serialize, PartialEq)]
15pub struct ExtractField {
16    /// The field name to extract from the response (None if extracting full body)
17    /// Supports nested paths: "uuid", "data.uuid", "results[0].uuid", "results[?name=\"global\"].uuid"
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub field: Option<String>,
20    /// Extract entire response body instead of a single field
21    #[serde(default)]
22    pub body: bool,
23    /// The name to store it as (defaults to field name if not specified)
24    /// Note: Deserialization accepts "as" via custom Deserialize impl, but serializes as "store_as"
25    pub store_as: String,
26    /// Keys to exclude when extracting full body (only used when body=true)
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub exclude: Vec<String>,
29    /// Match mode for filter expressions: "first" (default) or "last"
30    /// Used when field path contains a filter like results[?name="global"]
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub match_mode: Option<String>,
33}
34
35impl ExtractField {
36    /// Create a new extract field with the same name for field and storage
37    pub fn simple(field: String) -> Self {
38        Self {
39            store_as: field.clone(),
40            field: Some(field),
41            body: false,
42            exclude: Vec::new(),
43            match_mode: None,
44        }
45    }
46
47    /// Create a new extract field with an alias
48    pub fn aliased(field: String, store_as: String) -> Self {
49        Self {
50            field: Some(field),
51            store_as,
52            body: false,
53            exclude: Vec::new(),
54            match_mode: None,
55        }
56    }
57
58    /// Create a new extract field with an alias and match mode
59    pub fn aliased_with_match(field: String, store_as: String, match_mode: Option<String>) -> Self {
60        Self {
61            field: Some(field),
62            store_as,
63            body: false,
64            exclude: Vec::new(),
65            match_mode,
66        }
67    }
68
69    /// Create a full body extraction with optional key filtering
70    pub fn full_body(store_as: String, exclude: Vec<String>) -> Self {
71        Self {
72            field: None,
73            body: true,
74            store_as,
75            exclude,
76            match_mode: None,
77        }
78    }
79}
80
81impl<'de> Deserialize<'de> for ExtractField {
82    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
83    where
84        D: Deserializer<'de>,
85    {
86        use serde::de::{self, MapAccess, Visitor};
87
88        struct ExtractFieldVisitor;
89
90        impl<'de> Visitor<'de> for ExtractFieldVisitor {
91            type Value = ExtractField;
92
93            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
94                formatter.write_str("a string, an object with 'field' and optional 'as' keys, or an object with 'body: true' and 'as' keys")
95            }
96
97            // Handle simple string: "uuid"
98            fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
99            where
100                E: de::Error,
101            {
102                Ok(ExtractField::simple(value.to_string()))
103            }
104
105            // Handle object: {field: "uuid", as: "pool_uuid"} or {body: true, as: "vs_body", exclude: [...]}
106            fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
107            where
108                M: MapAccess<'de>,
109            {
110                let mut field: Option<String> = None;
111                let mut store_as: Option<String> = None;
112                let mut body: bool = false;
113                let mut exclude: Vec<String> = Vec::new();
114                let mut match_mode: Option<String> = None;
115
116                while let Some(key) = map.next_key::<String>()? {
117                    match key.as_str() {
118                        "field" => {
119                            field = Some(map.next_value()?);
120                        }
121                        "as" => {
122                            store_as = Some(map.next_value()?);
123                        }
124                        "body" => {
125                            body = map.next_value()?;
126                        }
127                        "exclude" => {
128                            exclude = map.next_value()?;
129                        }
130                        "match" | "match_mode" => {
131                            match_mode = Some(map.next_value()?);
132                        }
133                        _ => {
134                            let _: serde::de::IgnoredAny = map.next_value()?;
135                        }
136                    }
137                }
138
139                if body {
140                    // Full body extraction mode
141                    let store_as = store_as.ok_or_else(|| de::Error::missing_field("as"))?;
142                    Ok(ExtractField {
143                        field: None,
144                        body: true,
145                        store_as,
146                        exclude,
147                        match_mode: None,
148                    })
149                } else {
150                    // Field extraction mode
151                    let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
152                    let store_as = store_as.unwrap_or_else(|| field.clone());
153                    Ok(ExtractField {
154                        field: Some(field),
155                        body: false,
156                        store_as,
157                        exclude: Vec::new(),
158                        match_mode,
159                    })
160                }
161            }
162        }
163
164        deserializer.deserialize_any(ExtractFieldVisitor)
165    }
166}
167
168/// A single step in a CRUD flow
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct FlowStep {
171    /// The operation identifier (e.g., "POST /users" or operation_id)
172    pub operation: String,
173    /// Fields to extract from the response (for subsequent steps)
174    /// Supports multiple formats:
175    /// - Simple: "uuid" (extracts uuid, stores as uuid)
176    /// - Aliased: {field: "uuid", as: "pool_uuid"} (extracts uuid, stores as pool_uuid)
177    /// - Full body: {body: true, as: "vs_body", exclude: ["_last_modified"]}
178    #[serde(default)]
179    pub extract: Vec<ExtractField>,
180    /// Mapping of path/body variables to extracted values
181    /// Key: variable name in request, Value: extracted field name
182    #[serde(default)]
183    pub use_values: HashMap<String, String>,
184    /// Use a previously extracted body as the request body for this step
185    /// The value should match the 'as' name from a previous body extraction
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub use_body: Option<String>,
188    /// Merge/override fields in the extracted body before sending
189    /// Works with use_body to modify specific fields in the extracted body
190    /// Example: merge_body: {enabled: false, name: "updated-name"}
191    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192    pub merge_body: HashMap<String, serde_json::Value>,
193    /// Inject security attack payloads into this step's request
194    /// When true, security payloads from wafbench or configured attacks will be injected
195    #[serde(default)]
196    pub inject_attacks: bool,
197    /// Specific attack types to inject (if not all)
198    /// Examples: "sqli", "xss", "rce", "lfi"
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub attack_types: Vec<String>,
201    /// Optional description for this step
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub description: Option<String>,
204}
205
206impl FlowStep {
207    /// Create a new flow step
208    pub fn new(operation: String) -> Self {
209        Self {
210            operation,
211            extract: Vec::new(),
212            use_values: HashMap::new(),
213            use_body: None,
214            merge_body: HashMap::new(),
215            inject_attacks: false,
216            attack_types: Vec::new(),
217            description: None,
218        }
219    }
220
221    /// Add fields to extract from response (simple field names)
222    pub fn with_extract(mut self, fields: Vec<String>) -> Self {
223        self.extract = fields.into_iter().map(ExtractField::simple).collect();
224        self
225    }
226
227    /// Add fields to extract with aliases
228    pub fn with_extract_fields(mut self, fields: Vec<ExtractField>) -> Self {
229        self.extract = fields;
230        self
231    }
232
233    /// Add value mappings for this step
234    pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
235        self.use_values = values;
236        self
237    }
238
239    /// Use a previously extracted body as the request body
240    pub fn with_use_body(mut self, body_name: String) -> Self {
241        self.use_body = Some(body_name);
242        self
243    }
244
245    /// Merge/override fields in the extracted body
246    pub fn with_merge_body(mut self, merge: HashMap<String, serde_json::Value>) -> Self {
247        self.merge_body = merge;
248        self
249    }
250
251    /// Enable security attack injection for this step
252    pub fn with_inject_attacks(mut self, inject: bool) -> Self {
253        self.inject_attacks = inject;
254        self
255    }
256
257    /// Specify which attack types to inject
258    pub fn with_attack_types(mut self, types: Vec<String>) -> Self {
259        self.attack_types = types;
260        self
261    }
262
263    /// Add a description
264    pub fn with_description(mut self, description: String) -> Self {
265        self.description = Some(description);
266        self
267    }
268}
269
270/// A complete CRUD flow definition
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct CrudFlow {
273    /// Name of this flow
274    pub name: String,
275    /// Base path for this resource (e.g., "/users")
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub base_path: Option<String>,
278    /// Ordered list of steps in the flow
279    pub steps: Vec<FlowStep>,
280}
281
282impl CrudFlow {
283    /// Create a new CRUD flow with a name
284    pub fn new(name: String) -> Self {
285        Self {
286            name,
287            base_path: None,
288            steps: Vec::new(),
289        }
290    }
291
292    /// Set the base path
293    pub fn with_base_path(mut self, path: String) -> Self {
294        self.base_path = Some(path);
295        self
296    }
297
298    /// Add a step to the flow
299    pub fn add_step(&mut self, step: FlowStep) {
300        self.steps.push(step);
301    }
302
303    /// Get all fields that need to be extracted across all steps (returns field names)
304    /// Note: Only returns field names, not body extractions
305    pub fn get_all_extract_fields(&self) -> HashSet<String> {
306        self.steps
307            .iter()
308            .flat_map(|step| step.extract.iter().filter_map(|e| e.field.clone()))
309            .collect()
310    }
311
312    /// Get all extract field configurations across all steps
313    pub fn get_all_extract_configs(&self) -> Vec<&ExtractField> {
314        self.steps.iter().flat_map(|step| step.extract.iter()).collect()
315    }
316}
317
318/// CRUD flow configuration containing multiple flows
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct CrudFlowConfig {
321    /// List of CRUD flows
322    pub flows: Vec<CrudFlow>,
323    /// Default fields to extract if not specified
324    #[serde(default)]
325    pub default_extract_fields: Vec<String>,
326}
327
328impl Default for CrudFlowConfig {
329    fn default() -> Self {
330        Self {
331            flows: Vec::new(),
332            default_extract_fields: vec!["id".to_string(), "uuid".to_string()],
333        }
334    }
335}
336
337impl CrudFlowConfig {
338    /// Load configuration from a YAML file
339    pub fn from_file(path: &Path) -> Result<Self> {
340        let content = std::fs::read_to_string(path)
341            .map_err(|e| BenchError::Other(format!("Failed to read flow config: {}", e)))?;
342
343        Self::from_yaml(&content)
344    }
345
346    /// Parse configuration from YAML string
347    pub fn from_yaml(yaml: &str) -> Result<Self> {
348        serde_yaml::from_str(yaml)
349            .map_err(|e| BenchError::Other(format!("Failed to parse flow config: {}", e)))
350    }
351
352    /// Create configuration with a single flow
353    pub fn single_flow(flow: CrudFlow) -> Self {
354        Self {
355            flows: vec![flow],
356            ..Default::default()
357        }
358    }
359}
360
361/// Grouped operations by base path for CRUD detection
362#[derive(Debug, Clone)]
363pub struct ResourceOperations {
364    /// Base path (e.g., "/users")
365    pub base_path: String,
366    /// CREATE operation (typically POST on base path)
367    pub create: Option<ApiOperation>,
368    /// READ single operation (typically GET on {id} path)
369    pub read: Option<ApiOperation>,
370    /// UPDATE operation (typically PUT/PATCH on {id} path)
371    pub update: Option<ApiOperation>,
372    /// DELETE operation (typically DELETE on {id} path)
373    pub delete: Option<ApiOperation>,
374    /// LIST operation (typically GET on base path)
375    pub list: Option<ApiOperation>,
376}
377
378impl ResourceOperations {
379    /// Create a new resource operations group
380    pub fn new(base_path: String) -> Self {
381        Self {
382            base_path,
383            create: None,
384            read: None,
385            update: None,
386            delete: None,
387            list: None,
388        }
389    }
390
391    /// Check if this resource has CRUD operations
392    pub fn has_crud_operations(&self) -> bool {
393        self.create.is_some()
394            && (self.read.is_some() || self.update.is_some() || self.delete.is_some())
395    }
396
397    /// Get the ID parameter name from the path (e.g., "{id}", "{userId}")
398    pub fn get_id_param_name(&self) -> Option<String> {
399        // Look at read, update, or delete operations to find the ID parameter
400        let path = self
401            .read
402            .as_ref()
403            .or(self.update.as_ref())
404            .or(self.delete.as_ref())
405            .map(|op| &op.path)?;
406
407        // Extract parameter name from path like "/users/{id}" or "/users/{userId}"
408        extract_id_param_from_path(path)
409    }
410}
411
412/// Extract the ID parameter name from a path pattern
413fn extract_id_param_from_path(path: &str) -> Option<String> {
414    // Find the last path segment that looks like {paramName}
415    for segment in path.split('/').rev() {
416        if segment.starts_with('{') && segment.ends_with('}') {
417            return Some(segment[1..segment.len() - 1].to_string());
418        }
419    }
420    None
421}
422
423/// Extract the base path from a full path
424/// e.g., "/users/{id}" -> "/users"
425fn get_base_path(path: &str) -> String {
426    let segments: Vec<&str> = path.split('/').collect();
427    let mut base_segments = Vec::new();
428
429    for segment in segments {
430        if segment.starts_with('{') {
431            break;
432        }
433        if !segment.is_empty() {
434            base_segments.push(segment);
435        }
436    }
437
438    if base_segments.is_empty() {
439        "/".to_string()
440    } else {
441        format!("/{}", base_segments.join("/"))
442    }
443}
444
445/// Check if a path is a "detail" path (has ID parameter)
446fn is_detail_path(path: &str) -> bool {
447    path.contains('{') && path.contains('}')
448}
449
450/// Auto-detect CRUD flows from a list of operations
451pub struct CrudFlowDetector;
452
453impl CrudFlowDetector {
454    /// Detect CRUD flows from API operations
455    ///
456    /// Groups operations by base path and identifies CRUD patterns.
457    pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
458        // Group operations by base path
459        let mut resources: HashMap<String, ResourceOperations> = HashMap::new();
460
461        for op in operations {
462            let base_path = get_base_path(&op.path);
463            let is_detail = is_detail_path(&op.path);
464            let method = op.method.to_lowercase();
465
466            let resource = resources
467                .entry(base_path.clone())
468                .or_insert_with(|| ResourceOperations::new(base_path));
469
470            match (method.as_str(), is_detail) {
471                ("post", false) => resource.create = Some(op.clone()),
472                ("get", false) => resource.list = Some(op.clone()),
473                ("get", true) => resource.read = Some(op.clone()),
474                ("put", true) | ("patch", true) => resource.update = Some(op.clone()),
475                ("delete", true) => resource.delete = Some(op.clone()),
476                _ => {}
477            }
478        }
479
480        // Build flows from resources that have CRUD operations
481        resources
482            .into_values()
483            .filter(|r| r.has_crud_operations())
484            .map(|r| Self::build_flow_from_resource(&r))
485            .collect()
486    }
487
488    /// Build a CRUD flow from a resource's operations
489    fn build_flow_from_resource(resource: &ResourceOperations) -> CrudFlow {
490        let name = resource.base_path.trim_start_matches('/').replace('/', "_").to_string();
491
492        let mut flow =
493            CrudFlow::new(format!("{} CRUD", name)).with_base_path(resource.base_path.clone());
494
495        let id_param = resource.get_id_param_name().unwrap_or_else(|| "id".to_string());
496
497        // Step 1: CREATE (POST) - extract ID
498        if let Some(create_op) = &resource.create {
499            let step =
500                FlowStep::new(format!("{} {}", create_op.method.to_uppercase(), create_op.path))
501                    .with_extract(vec!["id".to_string(), "uuid".to_string()])
502                    .with_description("Create resource".to_string());
503            flow.add_step(step);
504        }
505
506        // Step 2: READ (GET) - verify creation
507        if let Some(read_op) = &resource.read {
508            let mut values = HashMap::new();
509            values.insert(id_param.clone(), "id".to_string());
510
511            let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
512                .with_values(values)
513                .with_description("Read created resource".to_string());
514            flow.add_step(step);
515        }
516
517        // Step 3: UPDATE (PUT/PATCH) - modify resource
518        if let Some(update_op) = &resource.update {
519            let mut values = HashMap::new();
520            values.insert(id_param.clone(), "id".to_string());
521
522            let step =
523                FlowStep::new(format!("{} {}", update_op.method.to_uppercase(), update_op.path))
524                    .with_values(values)
525                    .with_description("Update resource".to_string());
526            flow.add_step(step);
527        }
528
529        // Step 4: READ again (GET) - verify update
530        if let Some(read_op) = &resource.read {
531            let mut values = HashMap::new();
532            values.insert(id_param.clone(), "id".to_string());
533
534            let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
535                .with_values(values)
536                .with_description("Verify update".to_string());
537            flow.add_step(step);
538        }
539
540        // Step 5: DELETE - cleanup
541        if let Some(delete_op) = &resource.delete {
542            let mut values = HashMap::new();
543            values.insert(id_param.clone(), "id".to_string());
544
545            let step =
546                FlowStep::new(format!("{} {}", delete_op.method.to_uppercase(), delete_op.path))
547                    .with_values(values)
548                    .with_description("Delete resource".to_string());
549            flow.add_step(step);
550        }
551
552        flow
553    }
554
555    /// Merge auto-detected flows with user-provided configuration
556    ///
557    /// User configuration takes precedence over auto-detected flows.
558    pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
559        if !config.flows.is_empty() {
560            // If user provided flows, use those
561            config.flows.clone()
562        } else {
563            // Otherwise use auto-detected flows
564            detected
565        }
566    }
567}
568
569/// Context for CRUD flow execution (tracks extracted values)
570#[derive(Debug, Clone, Default)]
571pub struct FlowExecutionContext {
572    /// Extracted values from previous steps
573    /// Key: field name, Value: extracted value
574    pub extracted_values: HashMap<String, String>,
575    /// Current step index
576    pub current_step: usize,
577    /// Errors encountered
578    pub errors: Vec<String>,
579}
580
581impl FlowExecutionContext {
582    /// Create a new execution context
583    pub fn new() -> Self {
584        Self::default()
585    }
586
587    /// Store an extracted value
588    pub fn store_value(&mut self, key: String, value: String) {
589        self.extracted_values.insert(key, value);
590    }
591
592    /// Get an extracted value
593    pub fn get_value(&self, key: &str) -> Option<&String> {
594        self.extracted_values.get(key)
595    }
596
597    /// Advance to the next step
598    pub fn next_step(&mut self) {
599        self.current_step += 1;
600    }
601
602    /// Record an error
603    pub fn record_error(&mut self, error: String) {
604        self.errors.push(error);
605    }
606
607    /// Check if execution has errors
608    pub fn has_errors(&self) -> bool {
609        !self.errors.is_empty()
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use openapiv3::Operation;
617
618    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
619        ApiOperation {
620            method: method.to_string(),
621            path: path.to_string(),
622            operation: Operation::default(),
623            operation_id: operation_id.map(|s| s.to_string()),
624        }
625    }
626
627    #[test]
628    fn test_get_base_path() {
629        assert_eq!(get_base_path("/users"), "/users");
630        assert_eq!(get_base_path("/users/{id}"), "/users");
631        assert_eq!(get_base_path("/api/v1/users/{userId}"), "/api/v1/users");
632        assert_eq!(get_base_path("/resources/{resourceId}/items/{itemId}"), "/resources");
633        assert_eq!(get_base_path("/"), "/");
634    }
635
636    #[test]
637    fn test_is_detail_path() {
638        assert!(!is_detail_path("/users"));
639        assert!(is_detail_path("/users/{id}"));
640        assert!(is_detail_path("/users/{userId}"));
641        assert!(!is_detail_path("/users/list"));
642    }
643
644    #[test]
645    fn test_extract_id_param_from_path() {
646        assert_eq!(extract_id_param_from_path("/users/{id}"), Some("id".to_string()));
647        assert_eq!(extract_id_param_from_path("/users/{userId}"), Some("userId".to_string()));
648        assert_eq!(extract_id_param_from_path("/a/{b}/{c}"), Some("c".to_string()));
649        assert_eq!(extract_id_param_from_path("/users"), None);
650    }
651
652    #[test]
653    fn test_crud_flow_detection() {
654        let operations = vec![
655            create_test_operation("post", "/users", Some("createUser")),
656            create_test_operation("get", "/users", Some("listUsers")),
657            create_test_operation("get", "/users/{id}", Some("getUser")),
658            create_test_operation("put", "/users/{id}", Some("updateUser")),
659            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
660        ];
661
662        let flows = CrudFlowDetector::detect_flows(&operations);
663        assert_eq!(flows.len(), 1);
664
665        let flow = &flows[0];
666        assert!(flow.name.contains("users"));
667        assert_eq!(flow.steps.len(), 5); // CREATE, READ, UPDATE, READ, DELETE
668
669        // First step should be POST (create)
670        assert!(flow.steps[0].operation.starts_with("POST"));
671        // Should extract id
672        assert!(flow.steps[0].extract.iter().any(|e| e.field.as_deref() == Some("id")));
673    }
674
675    #[test]
676    fn test_multiple_resources_detection() {
677        let operations = vec![
678            // Users resource
679            create_test_operation("post", "/users", Some("createUser")),
680            create_test_operation("get", "/users/{id}", Some("getUser")),
681            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
682            // Posts resource
683            create_test_operation("post", "/posts", Some("createPost")),
684            create_test_operation("get", "/posts/{id}", Some("getPost")),
685            create_test_operation("put", "/posts/{id}", Some("updatePost")),
686        ];
687
688        let flows = CrudFlowDetector::detect_flows(&operations);
689        assert_eq!(flows.len(), 2);
690    }
691
692    #[test]
693    fn test_no_crud_without_create() {
694        let operations = vec![
695            // Only read operations - no CREATE
696            create_test_operation("get", "/users", Some("listUsers")),
697            create_test_operation("get", "/users/{id}", Some("getUser")),
698        ];
699
700        let flows = CrudFlowDetector::detect_flows(&operations);
701        assert!(flows.is_empty());
702    }
703
704    #[test]
705    fn test_flow_step_builder() {
706        let step = FlowStep::new("POST /users".to_string())
707            .with_extract(vec!["id".to_string(), "uuid".to_string()])
708            .with_description("Create a new user".to_string());
709
710        assert_eq!(step.operation, "POST /users");
711        assert_eq!(step.extract.len(), 2);
712        assert_eq!(step.description, Some("Create a new user".to_string()));
713    }
714
715    #[test]
716    fn test_flow_step_use_values() {
717        let mut values = HashMap::new();
718        values.insert("id".to_string(), "user_id".to_string());
719
720        let step = FlowStep::new("GET /users/{id}".to_string()).with_values(values);
721
722        assert_eq!(step.use_values.get("id"), Some(&"user_id".to_string()));
723    }
724
725    #[test]
726    fn test_crud_flow_config_from_yaml() {
727        let yaml = r#"
728flows:
729  - name: "User CRUD"
730    base_path: "/users"
731    steps:
732      - operation: "POST /users"
733        extract: ["id"]
734        description: "Create user"
735      - operation: "GET /users/{id}"
736        use_values:
737          id: "id"
738        description: "Get user"
739default_extract_fields:
740  - id
741  - uuid
742"#;
743
744        let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
745        assert_eq!(config.flows.len(), 1);
746        assert_eq!(config.flows[0].name, "User CRUD");
747        assert_eq!(config.flows[0].steps.len(), 2);
748        assert_eq!(config.default_extract_fields.len(), 2);
749    }
750
751    #[test]
752    fn test_execution_context() {
753        let mut ctx = FlowExecutionContext::new();
754
755        ctx.store_value("id".to_string(), "12345".to_string());
756        assert_eq!(ctx.get_value("id"), Some(&"12345".to_string()));
757
758        ctx.next_step();
759        assert_eq!(ctx.current_step, 1);
760
761        ctx.record_error("Something went wrong".to_string());
762        assert!(ctx.has_errors());
763    }
764
765    #[test]
766    fn test_resource_operations_has_crud() {
767        let mut resource = ResourceOperations::new("/users".to_string());
768        assert!(!resource.has_crud_operations());
769
770        resource.create = Some(create_test_operation("post", "/users", Some("createUser")));
771        assert!(!resource.has_crud_operations()); // Still needs read/update/delete
772
773        resource.read = Some(create_test_operation("get", "/users/{id}", Some("getUser")));
774        assert!(resource.has_crud_operations());
775    }
776
777    #[test]
778    fn test_get_id_param_name() {
779        let mut resource = ResourceOperations::new("/users".to_string());
780        resource.read = Some(create_test_operation("get", "/users/{userId}", Some("getUser")));
781
782        assert_eq!(resource.get_id_param_name(), Some("userId".to_string()));
783    }
784
785    #[test]
786    fn test_merge_with_config_user_provided() {
787        let detected = vec![CrudFlow::new("detected_flow".to_string())];
788
789        let mut config = CrudFlowConfig::default();
790        config.flows.push(CrudFlow::new("user_flow".to_string()));
791
792        let result = CrudFlowDetector::merge_with_config(detected, &config);
793        assert_eq!(result.len(), 1);
794        assert_eq!(result[0].name, "user_flow");
795    }
796
797    #[test]
798    fn test_merge_with_config_auto_detected() {
799        let detected = vec![CrudFlow::new("detected_flow".to_string())];
800
801        let config = CrudFlowConfig::default();
802
803        let result = CrudFlowDetector::merge_with_config(detected, &config);
804        assert_eq!(result.len(), 1);
805        assert_eq!(result[0].name, "detected_flow");
806    }
807
808    #[test]
809    fn test_crud_flow_get_all_extract_fields() {
810        let mut flow = CrudFlow::new("test".to_string());
811        flow.add_step(FlowStep::new("POST /test".to_string()).with_extract(vec!["id".to_string()]));
812        flow.add_step(
813            FlowStep::new("GET /test/{id}".to_string()).with_extract(vec!["uuid".to_string()]),
814        );
815
816        let fields = flow.get_all_extract_fields();
817        assert!(fields.contains(&"id".to_string()));
818        assert!(fields.contains(&"uuid".to_string()));
819        assert_eq!(fields.len(), 2);
820    }
821
822    #[test]
823    fn test_flow_step_with_merge_body() {
824        let mut merge = HashMap::new();
825        merge.insert("enabled".to_string(), serde_json::json!(false));
826        merge.insert("name".to_string(), serde_json::json!("updated-name"));
827
828        let step = FlowStep::new("PUT /resource/{id}".to_string())
829            .with_use_body("resource_body".to_string())
830            .with_merge_body(merge);
831
832        assert_eq!(step.use_body, Some("resource_body".to_string()));
833        assert_eq!(step.merge_body.get("enabled"), Some(&serde_json::json!(false)));
834        assert_eq!(step.merge_body.get("name"), Some(&serde_json::json!("updated-name")));
835    }
836
837    #[test]
838    fn test_flow_config_with_merge_body() {
839        let yaml = r#"
840flows:
841  - name: "Update Flow"
842    steps:
843      - operation: "GET /resource/{id}"
844        use_values:
845          id: "resource_id"
846        extract:
847          - body: true
848            as: resource_body
849            exclude: ["_last_modified"]
850        description: "Get resource"
851      - operation: "PUT /resource/{id}"
852        use_values:
853          id: "resource_id"
854        use_body: "resource_body"
855        merge_body:
856          enabled: false
857          name: "updated"
858        description: "Update resource"
859"#;
860
861        let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
862        assert_eq!(config.flows.len(), 1);
863        assert_eq!(config.flows[0].steps.len(), 2);
864
865        let put_step = &config.flows[0].steps[1];
866        assert_eq!(put_step.use_body, Some("resource_body".to_string()));
867        assert!(!put_step.merge_body.is_empty());
868        assert_eq!(put_step.merge_body.get("enabled"), Some(&serde_json::json!(false)));
869        assert_eq!(put_step.merge_body.get("name"), Some(&serde_json::json!("updated")));
870    }
871
872    #[test]
873    fn test_flow_step_with_inject_attacks() {
874        let step = FlowStep::new("POST /resource".to_string())
875            .with_inject_attacks(true)
876            .with_attack_types(vec!["sqli".to_string(), "xss".to_string()]);
877
878        assert!(step.inject_attacks);
879        assert_eq!(step.attack_types, vec!["sqli".to_string(), "xss".to_string()]);
880    }
881
882    #[test]
883    fn test_flow_config_with_inject_attacks() {
884        let yaml = r#"
885flows:
886  - name: "Security Test Flow"
887    steps:
888      - operation: "POST /pool"
889        inject_attacks: true
890        attack_types:
891          - sqli
892          - xss
893        description: "Create pool with attack payloads"
894"#;
895
896        let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
897        assert_eq!(config.flows.len(), 1);
898        assert_eq!(config.flows[0].steps.len(), 1);
899
900        let step = &config.flows[0].steps[0];
901        assert!(step.inject_attacks);
902        assert_eq!(step.attack_types, vec!["sqli".to_string(), "xss".to_string()]);
903    }
904}