veracode_platform/
sandbox.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use chrono::{DateTime, Utc};
4
5use crate::{VeracodeClient, VeracodeError};
6
7/// API error response structure
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ApiErrorResponse {
10    #[serde(rename = "_embedded")]
11    pub embedded: Option<ApiErrorEmbedded>,
12    pub fallback_type: Option<String>,
13    pub full_type: Option<String>,
14}
15
16/// Embedded API errors
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApiErrorEmbedded {
19    pub api_errors: Vec<ApiError>,
20}
21
22/// Individual API error
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ApiError {
25    pub id: String,
26    pub code: String,
27    pub title: String,
28    pub status: String,
29    pub source: Option<ApiErrorSource>,
30}
31
32/// API error source information
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApiErrorSource {
35    pub pointer: String,
36    pub parameter: String,
37}
38
39/// Represents a Veracode development sandbox
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Sandbox {
42    pub id: Option<u64>,
43    pub guid: String,
44    pub name: String,
45    pub description: Option<String>,
46    pub created: DateTime<Utc>,
47    pub modified: DateTime<Utc>,
48    pub auto_recreate: bool,
49    pub custom_fields: Option<HashMap<String, String>>,
50    pub owner: Option<String>,
51    pub owner_username: Option<String>,
52    pub organization_id: Option<u64>,
53    pub application_guid: Option<String>,
54    pub team_identifiers: Option<Vec<String>>,
55    pub scan_url: Option<String>,
56    pub last_scan_date: Option<DateTime<Utc>>,
57    pub status: Option<String>,
58    #[serde(rename = "_links")]
59    pub links: Option<serde_json::Value>,
60}
61
62/// Request payload for creating a new sandbox
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CreateSandboxRequest {
65    pub name: String,
66    pub description: Option<String>,
67    pub auto_recreate: Option<bool>,
68    pub custom_fields: Option<HashMap<String, String>>,
69    pub team_identifiers: Option<Vec<String>>,
70}
71
72/// Request payload for updating an existing sandbox
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct UpdateSandboxRequest {
75    pub name: Option<String>,
76    pub description: Option<String>,
77    pub auto_recreate: Option<bool>,
78    pub custom_fields: Option<HashMap<String, String>>,
79    pub team_identifiers: Option<Vec<String>>,
80}
81
82/// Response wrapper for sandbox list operations
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SandboxListResponse {
85    #[serde(rename = "_embedded")]
86    pub embedded: Option<SandboxEmbedded>,
87    pub page: Option<PageInfo>,
88    pub total: Option<u64>,
89}
90
91/// Embedded sandboxes in the list response
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SandboxEmbedded {
94    pub sandboxes: Vec<Sandbox>,
95}
96
97/// Page information for paginated responses
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PageInfo {
100    pub size: u64,
101    pub number: u64,
102    pub total_elements: u64,
103    pub total_pages: u64,
104}
105
106/// Represents a scan within a sandbox
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SandboxScan {
109    pub scan_id: u64,
110    pub scan_type: String,
111    pub status: String,
112    pub created: DateTime<Utc>,
113    pub modified: DateTime<Utc>,
114    pub scan_url: Option<String>,
115    pub results_ready: bool,
116    pub engine_version: Option<String>,
117}
118
119/// Query parameters for listing sandboxes
120#[derive(Debug, Clone, Default)]
121pub struct SandboxListParams {
122    pub name: Option<String>,
123    pub owner: Option<String>,
124    pub team: Option<String>,
125    pub page: Option<u64>,
126    pub size: Option<u64>,
127    pub modified_after: Option<DateTime<Utc>>,
128    pub modified_before: Option<DateTime<Utc>>,
129}
130
131impl SandboxListParams {
132    /// Convert to query parameters for HTTP requests
133    pub fn to_query_params(&self) -> Vec<(String, String)> {
134        let mut params = Vec::new();
135        
136        if let Some(name) = &self.name {
137            params.push(("name".to_string(), name.clone()));
138        }
139        if let Some(owner) = &self.owner {
140            params.push(("owner".to_string(), owner.clone()));
141        }
142        if let Some(team) = &self.team {
143            params.push(("team".to_string(), team.clone()));
144        }
145        if let Some(page) = self.page {
146            params.push(("page".to_string(), page.to_string()));
147        }
148        if let Some(size) = self.size {
149            params.push(("size".to_string(), size.to_string()));
150        }
151        if let Some(modified_after) = self.modified_after {
152            params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
153        }
154        if let Some(modified_before) = self.modified_before {
155            params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
156        }
157        
158        params
159    }
160}
161
162/// Sandbox-specific error types that extend the base VeracodeError
163#[derive(Debug)]
164pub enum SandboxError {
165    /// Veracode API error
166    Api(VeracodeError),
167    /// Sandbox not found
168    NotFound,
169    /// Invalid sandbox name or configuration
170    InvalidInput(String),
171    /// Maximum number of sandboxes reached
172    LimitExceeded,
173    /// Sandbox operation not allowed
174    OperationNotAllowed(String),
175    /// Sandbox already exists
176    AlreadyExists(String),
177}
178
179impl std::fmt::Display for SandboxError {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match self {
182            SandboxError::Api(err) => write!(f, "API error: {err}"),
183            SandboxError::NotFound => write!(f, "Sandbox not found"),
184            SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
185            SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
186            SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
187            SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
188        }
189    }
190}
191
192impl std::error::Error for SandboxError {}
193
194impl From<VeracodeError> for SandboxError {
195    fn from(err: VeracodeError) -> Self {
196        SandboxError::Api(err)
197    }
198}
199
200impl From<reqwest::Error> for SandboxError {
201    fn from(err: reqwest::Error) -> Self {
202        SandboxError::Api(VeracodeError::Http(err))
203    }
204}
205
206impl From<serde_json::Error> for SandboxError {
207    fn from(err: serde_json::Error) -> Self {
208        SandboxError::Api(VeracodeError::Serialization(err))
209    }
210}
211
212/// Veracode Sandbox API operations
213pub struct SandboxApi<'a> {
214    client: &'a VeracodeClient,
215}
216
217impl<'a> SandboxApi<'a> {
218    /// Create a new SandboxApi instance
219    pub fn new(client: &'a VeracodeClient) -> Self {
220        Self { client }
221    }
222
223    /// List all sandboxes for a given application
224    ///
225    /// # Arguments
226    ///
227    /// * `application_guid` - The GUID of the application
228    /// * `params` - Optional query parameters for filtering
229    ///
230    /// # Returns
231    ///
232    /// A `Result` containing a list of sandboxes or an error.
233    pub async fn list_sandboxes(
234        &self,
235        application_guid: &str,
236        params: Option<SandboxListParams>,
237    ) -> Result<Vec<Sandbox>, SandboxError> {
238        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
239        
240        let query_params = params.map(|p| p.to_query_params());
241        
242        let response = self.client.get(&endpoint, query_params.as_deref()).await?;
243        
244        let status = response.status().as_u16();
245        match status {
246            200 => {
247                let sandbox_response: SandboxListResponse = response.json().await?;
248                Ok(sandbox_response.embedded
249                    .map(|e| e.sandboxes)
250                    .unwrap_or_default())
251            }
252            404 => Err(SandboxError::NotFound),
253            _ => {
254                let error_text = response.text().await.unwrap_or_default();
255                Err(SandboxError::Api(VeracodeError::InvalidResponse(
256                    format!("HTTP {status}: {error_text}")
257                )))
258            }
259        }
260    }
261
262    /// Get a specific sandbox by GUID
263    ///
264    /// # Arguments
265    ///
266    /// * `application_guid` - The GUID of the application
267    /// * `sandbox_guid` - The GUID of the sandbox
268    ///
269    /// # Returns
270    ///
271    /// A `Result` containing the sandbox or an error.
272    pub async fn get_sandbox(
273        &self,
274        application_guid: &str,
275        sandbox_guid: &str,
276    ) -> Result<Sandbox, SandboxError> {
277        let endpoint = format!(
278            "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}"
279        );
280
281        let response = self.client.get(&endpoint, None).await?;
282
283        let status = response.status().as_u16();
284        match status {
285            200 => {
286                let sandbox: Sandbox = response.json().await?;
287                Ok(sandbox)
288            }
289            404 => Err(SandboxError::NotFound),
290            _ => {
291                let error_text = response.text().await.unwrap_or_default();
292                Err(SandboxError::Api(VeracodeError::InvalidResponse(
293                    format!("HTTP {status}: {error_text}")
294                )))
295            }
296        }
297    }
298
299    /// Create a new sandbox
300    ///
301    /// # Arguments
302    ///
303    /// * `application_guid` - The GUID of the application
304    /// * `request` - The sandbox creation request
305    ///
306    /// # Returns
307    ///
308    /// A `Result` containing the created sandbox or an error.
309    pub async fn create_sandbox(
310        &self,
311        application_guid: &str,
312        request: CreateSandboxRequest,
313    ) -> Result<Sandbox, SandboxError> {
314        // Validate the request
315        Self::validate_create_request(&request)?;
316
317        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
318
319        let response = self.client.post(&endpoint, Some(&request)).await?;
320
321        let status = response.status().as_u16();
322        match status {
323            200 | 201 => {
324                let sandbox: Sandbox = response.json().await?;
325                Ok(sandbox)
326            }
327            400 => {
328                let error_text = response.text().await.unwrap_or_default();
329                
330                // Try to parse the structured error response
331                if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text) {
332                    if let Some(embedded) = error_response.embedded {
333                        for api_error in embedded.api_errors {
334                            if api_error.title.contains("already exists") {
335                                return Err(SandboxError::AlreadyExists(api_error.title));
336                            }
337                            if api_error.title.contains("limit") || api_error.title.contains("maximum") {
338                                return Err(SandboxError::LimitExceeded);
339                            }
340                            if api_error.title.contains("Json Parse Error") || api_error.title.contains("Cannot deserialize") {
341                                return Err(SandboxError::InvalidInput(format!("JSON parsing error: {}", api_error.title)));
342                            }
343                        }
344                    }
345                }
346                
347                // Fallback to string matching for backwards compatibility
348                if error_text.contains("limit") || error_text.contains("maximum") {
349                    Err(SandboxError::LimitExceeded)
350                } else if error_text.contains("already exists") {
351                    Err(SandboxError::AlreadyExists(error_text))
352                } else {
353                    Err(SandboxError::InvalidInput(error_text))
354                }
355            }
356            404 => Err(SandboxError::NotFound),
357            _ => {
358                let error_text = response.text().await.unwrap_or_default();
359                Err(SandboxError::Api(VeracodeError::InvalidResponse(
360                    format!("HTTP {status}: {error_text}")
361                )))
362            }
363        }
364    }
365
366    /// Update an existing sandbox
367    ///
368    /// # Arguments
369    ///
370    /// * `application_guid` - The GUID of the application
371    /// * `sandbox_guid` - The GUID of the sandbox to update
372    /// * `request` - The sandbox update request
373    ///
374    /// # Returns
375    ///
376    /// A `Result` containing the updated sandbox or an error.
377    pub async fn update_sandbox(
378        &self,
379        application_guid: &str,
380        sandbox_guid: &str,
381        request: UpdateSandboxRequest,
382    ) -> Result<Sandbox, SandboxError> {
383        // Validate the request
384        Self::validate_update_request(&request)?;
385
386        let endpoint = format!(
387            "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}"
388        );
389
390        let response = self.client.put(&endpoint, Some(&request)).await?;
391
392        let status = response.status().as_u16();
393        match status {
394            200 => {
395                let sandbox: Sandbox = response.json().await?;
396                Ok(sandbox)
397            }
398            400 => {
399                let error_text = response.text().await.unwrap_or_default();
400                Err(SandboxError::InvalidInput(error_text))
401            }
402            404 => Err(SandboxError::NotFound),
403            _ => {
404                let error_text = response.text().await.unwrap_or_default();
405                Err(SandboxError::Api(VeracodeError::InvalidResponse(
406                    format!("HTTP {status}: {error_text}")
407                )))
408            }
409        }
410    }
411
412    /// Delete a sandbox
413    ///
414    /// # Arguments
415    ///
416    /// * `application_guid` - The GUID of the application
417    /// * `sandbox_guid` - The GUID of the sandbox to delete
418    ///
419    /// # Returns
420    ///
421    /// A `Result` indicating success or failure.
422    pub async fn delete_sandbox(
423        &self,
424        application_guid: &str,
425        sandbox_guid: &str,
426    ) -> Result<(), SandboxError> {
427        let endpoint = format!(
428            "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}"
429        );
430
431        let response = self.client.delete(&endpoint).await?;
432
433        let status = response.status().as_u16();
434        match status {
435            204 => Ok(()),
436            404 => Err(SandboxError::NotFound),
437            409 => {
438                let error_text = response.text().await.unwrap_or_default();
439                Err(SandboxError::OperationNotAllowed(error_text))
440            }
441            _ => {
442                let error_text = response.text().await.unwrap_or_default();
443                Err(SandboxError::Api(VeracodeError::InvalidResponse(
444                    format!("HTTP {status}: {error_text}")
445                )))
446            }
447        }
448    }
449
450    /// Promote a sandbox scan to the policy sandbox
451    ///
452    /// # Arguments
453    ///
454    /// * `application_guid` - The GUID of the application
455    /// * `sandbox_guid` - The GUID of the sandbox to promote
456    /// * `delete_on_promote` - Whether to delete the sandbox after promotion
457    ///
458    /// # Returns
459    ///
460    /// A `Result` indicating success or failure.
461    pub async fn promote_sandbox_scan(
462        &self,
463        application_guid: &str,
464        sandbox_guid: &str,
465        delete_on_promote: bool,
466    ) -> Result<(), SandboxError> {
467        let endpoint = if delete_on_promote {
468            format!(
469                "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
470            )
471        } else {
472            format!(
473                "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote"
474            )
475        };
476
477        let response = self.client.post(&endpoint, None::<&()>).await?;
478
479        let status = response.status().as_u16();
480        match status {
481            200 | 204 => Ok(()),
482            404 => Err(SandboxError::NotFound),
483            409 => {
484                let error_text = response.text().await.unwrap_or_default();
485                Err(SandboxError::OperationNotAllowed(error_text))
486            }
487            _ => {
488                let error_text = response.text().await.unwrap_or_default();
489                Err(SandboxError::Api(VeracodeError::InvalidResponse(
490                    format!("HTTP {status}: {error_text}")
491                )))
492            }
493        }
494    }
495
496    /// Get sandbox scan information
497    ///
498    /// # Arguments
499    ///
500    /// * `application_guid` - The GUID of the application
501    /// * `sandbox_guid` - The GUID of the sandbox
502    ///
503    /// # Returns
504    ///
505    /// A `Result` containing a list of scans or an error.
506    pub async fn get_sandbox_scans(
507        &self,
508        application_guid: &str,
509        sandbox_guid: &str,
510    ) -> Result<Vec<SandboxScan>, SandboxError> {
511        let endpoint = format!(
512            "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans"
513        );
514
515        let response = self.client.get(&endpoint, None).await?;
516
517        let status = response.status().as_u16();
518        match status {
519            200 => {
520                let scans: Vec<SandboxScan> = response.json().await?;
521                Ok(scans)
522            }
523            404 => Err(SandboxError::NotFound),
524            _ => {
525                let error_text = response.text().await.unwrap_or_default();
526                Err(SandboxError::Api(VeracodeError::InvalidResponse(
527                    format!("HTTP {status}: {error_text}")
528                )))
529            }
530        }
531    }
532
533    /// Check if a sandbox exists
534    ///
535    /// # Arguments
536    ///
537    /// * `application_guid` - The GUID of the application
538    /// * `sandbox_guid` - The GUID of the sandbox
539    ///
540    /// # Returns
541    ///
542    /// A `Result` containing a boolean indicating if the sandbox exists.
543    pub async fn sandbox_exists(
544        &self,
545        application_guid: &str,
546        sandbox_guid: &str,
547    ) -> Result<bool, SandboxError> {
548        match self.get_sandbox(application_guid, sandbox_guid).await {
549            Ok(_) => Ok(true),
550            Err(SandboxError::NotFound) => Ok(false),
551            Err(e) => Err(e),
552        }
553    }
554
555    /// Get sandbox by name
556    ///
557    /// # Arguments
558    ///
559    /// * `application_guid` - The GUID of the application
560    /// * `name` - The name of the sandbox to find
561    ///
562    /// # Returns
563    ///
564    /// A `Result` containing the sandbox if found, or None if not found.
565    pub async fn get_sandbox_by_name(
566        &self,
567        application_guid: &str,
568        name: &str,
569    ) -> Result<Option<Sandbox>, SandboxError> {
570        let params = SandboxListParams {
571            name: Some(name.to_string()),
572            ..Default::default()
573        };
574
575        let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
576        Ok(sandboxes.into_iter().find(|s| s.name == name))
577    }
578
579    /// Validate sandbox creation request
580    fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
581        if request.name.is_empty() {
582            return Err(SandboxError::InvalidInput("Sandbox name cannot be empty".to_string()));
583        }
584        if request.name.len() > 256 {
585            return Err(SandboxError::InvalidInput("Sandbox name too long (max 256 characters)".to_string()));
586        }
587        
588        // Check for invalid characters in name
589        if request.name.contains(['<', '>', '"', '&', '\'']) {
590            return Err(SandboxError::InvalidInput("Sandbox name contains invalid characters".to_string()));
591        }
592        
593        Ok(())
594    }
595
596    /// Validate sandbox update request
597    fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
598        if let Some(name) = &request.name {
599            if name.is_empty() {
600                return Err(SandboxError::InvalidInput("Sandbox name cannot be empty".to_string()));
601            }
602            if name.len() > 256 {
603                return Err(SandboxError::InvalidInput("Sandbox name too long (max 256 characters)".to_string()));
604            }
605            
606            // Check for invalid characters in name
607            if name.contains(['<', '>', '"', '&', '\'']) {
608                return Err(SandboxError::InvalidInput("Sandbox name contains invalid characters".to_string()));
609            }
610        }
611        
612        Ok(())
613    }
614}
615
616/// Convenience methods for common sandbox operations
617impl<'a> SandboxApi<'a> {
618    /// Create a simple sandbox with just a name
619    ///
620    /// # Arguments
621    ///
622    /// * `application_guid` - The GUID of the application
623    /// * `name` - The name of the sandbox
624    ///
625    /// # Returns
626    ///
627    /// A `Result` containing the created sandbox or an error.
628    pub async fn create_simple_sandbox(
629        &self,
630        application_guid: &str,
631        name: &str,
632    ) -> Result<Sandbox, SandboxError> {
633        let request = CreateSandboxRequest {
634            name: name.to_string(),
635            description: None,
636            auto_recreate: None,
637            custom_fields: None,
638            team_identifiers: None,
639        };
640
641        self.create_sandbox(application_guid, request).await
642    }
643
644    /// Create a sandbox with auto-recreate enabled
645    ///
646    /// # Arguments
647    ///
648    /// * `application_guid` - The GUID of the application
649    /// * `name` - The name of the sandbox
650    /// * `description` - Optional description
651    ///
652    /// # Returns
653    ///
654    /// A `Result` containing the created sandbox or an error.
655    pub async fn create_auto_recreate_sandbox(
656        &self,
657        application_guid: &str,
658        name: &str,
659        description: Option<String>,
660    ) -> Result<Sandbox, SandboxError> {
661        let request = CreateSandboxRequest {
662            name: name.to_string(),
663            description,
664            auto_recreate: Some(true),
665            custom_fields: None,
666            team_identifiers: None,
667        };
668
669        self.create_sandbox(application_guid, request).await
670    }
671
672    /// Update sandbox name
673    ///
674    /// # Arguments
675    ///
676    /// * `application_guid` - The GUID of the application
677    /// * `sandbox_guid` - The GUID of the sandbox
678    /// * `new_name` - The new name for the sandbox
679    ///
680    /// # Returns
681    ///
682    /// A `Result` containing the updated sandbox or an error.
683    pub async fn update_sandbox_name(
684        &self,
685        application_guid: &str,
686        sandbox_guid: &str,
687        new_name: &str,
688    ) -> Result<Sandbox, SandboxError> {
689        let request = UpdateSandboxRequest {
690            name: Some(new_name.to_string()),
691            description: None,
692            auto_recreate: None,
693            custom_fields: None,
694            team_identifiers: None,
695        };
696
697        self.update_sandbox(application_guid, sandbox_guid, request).await
698    }
699
700    /// Count sandboxes for an application
701    ///
702    /// # Arguments
703    ///
704    /// * `application_guid` - The GUID of the application
705    ///
706    /// # Returns
707    ///
708    /// A `Result` containing the count of sandboxes or an error.
709    pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
710        let sandboxes = self.list_sandboxes(application_guid, None).await?;
711        Ok(sandboxes.len())
712    }
713
714    /// Get numeric sandbox_id from sandbox GUID.
715    ///
716    /// This is needed for XML API operations that require numeric IDs.
717    ///
718    /// # Arguments
719    ///
720    /// * `application_guid` - The GUID of the application
721    /// * `sandbox_guid` - The sandbox GUID
722    ///
723    /// # Returns
724    ///
725    /// A `Result` containing the numeric sandbox_id as a string.
726    pub async fn get_sandbox_id_from_guid(
727        &self,
728        application_guid: &str,
729        sandbox_guid: &str,
730    ) -> Result<String, SandboxError> {
731        let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
732        match sandbox.id {
733            Some(id) => Ok(id.to_string()),
734            None => Err(SandboxError::InvalidInput("Sandbox has no numeric ID".to_string())),
735        }
736    }
737
738    /// Create sandbox if it doesn't exist, or return existing sandbox.
739    ///
740    /// This method implements the "check and create" pattern commonly needed
741    /// for automated workflows.
742    ///
743    /// # Arguments
744    ///
745    /// * `application_guid` - The GUID of the application
746    /// * `name` - The name of the sandbox
747    /// * `description` - Optional description for new sandboxes
748    ///
749    /// # Returns
750    ///
751    /// A `Result` containing the sandbox (existing or newly created).
752    pub async fn create_sandbox_if_not_exists(
753        &self,
754        application_guid: &str,
755        name: &str,
756        description: Option<String>,
757    ) -> Result<Sandbox, SandboxError> {
758        // First, check if sandbox already exists
759        if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
760            return Ok(existing_sandbox);
761        }
762
763        // Sandbox doesn't exist, create it
764        let create_request = CreateSandboxRequest {
765            name: name.to_string(),
766            description,
767            auto_recreate: Some(true), // Enable auto-recreate by default for CI/CD
768            custom_fields: None,
769            team_identifiers: None,
770        };
771
772        self.create_sandbox(application_guid, create_request).await
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn test_validate_create_request() {
782        // Valid request
783        let valid_request = CreateSandboxRequest {
784            name: "valid-sandbox".to_string(),
785            description: None,
786            auto_recreate: None,
787            custom_fields: None,
788            team_identifiers: None,
789        };
790        assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
791
792        // Empty name
793        let empty_name_request = CreateSandboxRequest {
794            name: String::new(),
795            description: None,
796            auto_recreate: None,
797            custom_fields: None,
798            team_identifiers: None,
799        };
800        assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
801
802        // Long name
803        let long_name_request = CreateSandboxRequest {
804            name: "x".repeat(300),
805            description: None,
806            auto_recreate: None,
807            custom_fields: None,
808            team_identifiers: None,
809        };
810        assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
811
812        // Invalid characters
813        let invalid_char_request = CreateSandboxRequest {
814            name: "invalid<name>".to_string(),
815            description: None,
816            auto_recreate: None,
817            custom_fields: None,
818            team_identifiers: None,
819        };
820        assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
821    }
822
823    #[test]
824    fn test_sandbox_list_params_to_query() {
825        let params = SandboxListParams {
826            name: Some("test".to_string()),
827            page: Some(1),
828            size: Some(10),
829            ..Default::default()
830        };
831
832        let query_params = params.to_query_params();
833        assert_eq!(query_params.len(), 3);
834        assert!(query_params.contains(&("name".to_string(), "test".to_string())));
835        assert!(query_params.contains(&("page".to_string(), "1".to_string())));
836        assert!(query_params.contains(&("size".to_string(), "10".to_string())));
837    }
838
839    #[test]
840    fn test_sandbox_error_display() {
841        let error = SandboxError::NotFound;
842        assert_eq!(error.to_string(), "Sandbox not found");
843
844        let error = SandboxError::InvalidInput("test".to_string());
845        assert_eq!(error.to_string(), "Invalid input: test");
846
847        let error = SandboxError::LimitExceeded;
848        assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
849    }
850}