1use 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#[derive(Debug, Clone, Serialize, PartialEq)]
15pub struct ExtractField {
16 #[serde(skip_serializing_if = "Option::is_none")]
19 pub field: Option<String>,
20 #[serde(default)]
22 pub body: bool,
23 pub store_as: String,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub exclude: Vec<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub match_mode: Option<String>,
33}
34
35impl ExtractField {
36 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct FlowStep {
171 pub operation: String,
173 #[serde(default)]
179 pub extract: Vec<ExtractField>,
180 #[serde(default)]
183 pub use_values: HashMap<String, String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
187 pub use_body: Option<String>,
188 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192 pub merge_body: HashMap<String, serde_json::Value>,
193 #[serde(default)]
196 pub inject_attacks: bool,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub attack_types: Vec<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub description: Option<String>,
204}
205
206impl FlowStep {
207 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 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 pub fn with_extract_fields(mut self, fields: Vec<ExtractField>) -> Self {
229 self.extract = fields;
230 self
231 }
232
233 pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
235 self.use_values = values;
236 self
237 }
238
239 pub fn with_use_body(mut self, body_name: String) -> Self {
241 self.use_body = Some(body_name);
242 self
243 }
244
245 pub fn with_merge_body(mut self, merge: HashMap<String, serde_json::Value>) -> Self {
247 self.merge_body = merge;
248 self
249 }
250
251 pub fn with_inject_attacks(mut self, inject: bool) -> Self {
253 self.inject_attacks = inject;
254 self
255 }
256
257 pub fn with_attack_types(mut self, types: Vec<String>) -> Self {
259 self.attack_types = types;
260 self
261 }
262
263 pub fn with_description(mut self, description: String) -> Self {
265 self.description = Some(description);
266 self
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct CrudFlow {
273 pub name: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub base_path: Option<String>,
278 pub steps: Vec<FlowStep>,
280}
281
282impl CrudFlow {
283 pub fn new(name: String) -> Self {
285 Self {
286 name,
287 base_path: None,
288 steps: Vec::new(),
289 }
290 }
291
292 pub fn with_base_path(mut self, path: String) -> Self {
294 self.base_path = Some(path);
295 self
296 }
297
298 pub fn add_step(&mut self, step: FlowStep) {
300 self.steps.push(step);
301 }
302
303 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 pub fn get_all_extract_configs(&self) -> Vec<&ExtractField> {
314 self.steps.iter().flat_map(|step| step.extract.iter()).collect()
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct CrudFlowConfig {
321 pub flows: Vec<CrudFlow>,
323 #[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 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 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 pub fn single_flow(flow: CrudFlow) -> Self {
354 Self {
355 flows: vec![flow],
356 ..Default::default()
357 }
358 }
359}
360
361#[derive(Debug, Clone)]
363pub struct ResourceOperations {
364 pub base_path: String,
366 pub create: Option<ApiOperation>,
368 pub read: Option<ApiOperation>,
370 pub update: Option<ApiOperation>,
372 pub delete: Option<ApiOperation>,
374 pub list: Option<ApiOperation>,
376}
377
378impl ResourceOperations {
379 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 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 pub fn get_id_param_name(&self) -> Option<String> {
399 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_id_param_from_path(path)
409 }
410}
411
412fn extract_id_param_from_path(path: &str) -> Option<String> {
414 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
423fn 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
445fn is_detail_path(path: &str) -> bool {
447 path.contains('{') && path.contains('}')
448}
449
450pub struct CrudFlowDetector;
452
453impl CrudFlowDetector {
454 pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
458 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 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 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 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 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 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 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 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 pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
559 if !config.flows.is_empty() {
560 config.flows.clone()
562 } else {
563 detected
565 }
566 }
567}
568
569#[derive(Debug, Clone, Default)]
571pub struct FlowExecutionContext {
572 pub extracted_values: HashMap<String, String>,
575 pub current_step: usize,
577 pub errors: Vec<String>,
579}
580
581impl FlowExecutionContext {
582 pub fn new() -> Self {
584 Self::default()
585 }
586
587 pub fn store_value(&mut self, key: String, value: String) {
589 self.extracted_values.insert(key, value);
590 }
591
592 pub fn get_value(&self, key: &str) -> Option<&String> {
594 self.extracted_values.get(key)
595 }
596
597 pub fn next_step(&mut self) {
599 self.current_step += 1;
600 }
601
602 pub fn record_error(&mut self, error: String) {
604 self.errors.push(error);
605 }
606
607 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); assert!(flow.steps[0].operation.starts_with("POST"));
671 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 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 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 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()); 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}