veracode_platform/
sandbox.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
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
249                    .embedded
250                    .map(|e| e.sandboxes)
251                    .unwrap_or_default())
252            }
253            404 => Err(SandboxError::NotFound),
254            _ => {
255                let error_text = response.text().await.unwrap_or_default();
256                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
257                    "HTTP {status}: {error_text}"
258                ))))
259            }
260        }
261    }
262
263    /// Get a specific sandbox by GUID
264    ///
265    /// # Arguments
266    ///
267    /// * `application_guid` - The GUID of the application
268    /// * `sandbox_guid` - The GUID of the sandbox
269    ///
270    /// # Returns
271    ///
272    /// A `Result` containing the sandbox or an error.
273    pub async fn get_sandbox(
274        &self,
275        application_guid: &str,
276        sandbox_guid: &str,
277    ) -> Result<Sandbox, SandboxError> {
278        let endpoint =
279            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
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(format!(
293                    "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")
338                                || api_error.title.contains("maximum")
339                            {
340                                return Err(SandboxError::LimitExceeded);
341                            }
342                            if api_error.title.contains("Json Parse Error")
343                                || api_error.title.contains("Cannot deserialize")
344                            {
345                                return Err(SandboxError::InvalidInput(format!(
346                                    "JSON parsing error: {}",
347                                    api_error.title
348                                )));
349                            }
350                        }
351                    }
352                }
353
354                // Fallback to string matching for backwards compatibility
355                if error_text.contains("limit") || error_text.contains("maximum") {
356                    Err(SandboxError::LimitExceeded)
357                } else if error_text.contains("already exists") {
358                    Err(SandboxError::AlreadyExists(error_text))
359                } else {
360                    Err(SandboxError::InvalidInput(error_text))
361                }
362            }
363            404 => Err(SandboxError::NotFound),
364            _ => {
365                let error_text = response.text().await.unwrap_or_default();
366                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
367                    "HTTP {status}: {error_text}"
368                ))))
369            }
370        }
371    }
372
373    /// Update an existing sandbox
374    ///
375    /// # Arguments
376    ///
377    /// * `application_guid` - The GUID of the application
378    /// * `sandbox_guid` - The GUID of the sandbox to update
379    /// * `request` - The sandbox update request
380    ///
381    /// # Returns
382    ///
383    /// A `Result` containing the updated sandbox or an error.
384    pub async fn update_sandbox(
385        &self,
386        application_guid: &str,
387        sandbox_guid: &str,
388        request: UpdateSandboxRequest,
389    ) -> Result<Sandbox, SandboxError> {
390        // Validate the request
391        Self::validate_update_request(&request)?;
392
393        let endpoint =
394            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
395
396        let response = self.client.put(&endpoint, Some(&request)).await?;
397
398        let status = response.status().as_u16();
399        match status {
400            200 => {
401                let sandbox: Sandbox = response.json().await?;
402                Ok(sandbox)
403            }
404            400 => {
405                let error_text = response.text().await.unwrap_or_default();
406                Err(SandboxError::InvalidInput(error_text))
407            }
408            404 => Err(SandboxError::NotFound),
409            _ => {
410                let error_text = response.text().await.unwrap_or_default();
411                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
412                    "HTTP {status}: {error_text}"
413                ))))
414            }
415        }
416    }
417
418    /// Delete a sandbox
419    ///
420    /// # Arguments
421    ///
422    /// * `application_guid` - The GUID of the application
423    /// * `sandbox_guid` - The GUID of the sandbox to delete
424    ///
425    /// # Returns
426    ///
427    /// A `Result` indicating success or failure.
428    pub async fn delete_sandbox(
429        &self,
430        application_guid: &str,
431        sandbox_guid: &str,
432    ) -> Result<(), SandboxError> {
433        let endpoint =
434            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
435
436        let response = self.client.delete(&endpoint).await?;
437
438        let status = response.status().as_u16();
439        match status {
440            204 => Ok(()),
441            404 => Err(SandboxError::NotFound),
442            409 => {
443                let error_text = response.text().await.unwrap_or_default();
444                Err(SandboxError::OperationNotAllowed(error_text))
445            }
446            _ => {
447                let error_text = response.text().await.unwrap_or_default();
448                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
449                    "HTTP {status}: {error_text}"
450                ))))
451            }
452        }
453    }
454
455    /// Promote a sandbox scan to the policy sandbox
456    ///
457    /// # Arguments
458    ///
459    /// * `application_guid` - The GUID of the application
460    /// * `sandbox_guid` - The GUID of the sandbox to promote
461    /// * `delete_on_promote` - Whether to delete the sandbox after promotion
462    ///
463    /// # Returns
464    ///
465    /// A `Result` indicating success or failure.
466    pub async fn promote_sandbox_scan(
467        &self,
468        application_guid: &str,
469        sandbox_guid: &str,
470        delete_on_promote: bool,
471    ) -> Result<(), SandboxError> {
472        let endpoint = if delete_on_promote {
473            format!(
474                "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
475            )
476        } else {
477            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
478        };
479
480        let response = self.client.post(&endpoint, None::<&()>).await?;
481
482        let status = response.status().as_u16();
483        match status {
484            200 | 204 => Ok(()),
485            404 => Err(SandboxError::NotFound),
486            409 => {
487                let error_text = response.text().await.unwrap_or_default();
488                Err(SandboxError::OperationNotAllowed(error_text))
489            }
490            _ => {
491                let error_text = response.text().await.unwrap_or_default();
492                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
493                    "HTTP {status}: {error_text}"
494                ))))
495            }
496        }
497    }
498
499    /// Get sandbox scan information
500    ///
501    /// # Arguments
502    ///
503    /// * `application_guid` - The GUID of the application
504    /// * `sandbox_guid` - The GUID of the sandbox
505    ///
506    /// # Returns
507    ///
508    /// A `Result` containing a list of scans or an error.
509    pub async fn get_sandbox_scans(
510        &self,
511        application_guid: &str,
512        sandbox_guid: &str,
513    ) -> Result<Vec<SandboxScan>, SandboxError> {
514        let endpoint =
515            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
516
517        let response = self.client.get(&endpoint, None).await?;
518
519        let status = response.status().as_u16();
520        match status {
521            200 => {
522                let scans: Vec<SandboxScan> = response.json().await?;
523                Ok(scans)
524            }
525            404 => Err(SandboxError::NotFound),
526            _ => {
527                let error_text = response.text().await.unwrap_or_default();
528                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
529                    "HTTP {status}: {error_text}"
530                ))))
531            }
532        }
533    }
534
535    /// Check if a sandbox exists
536    ///
537    /// # Arguments
538    ///
539    /// * `application_guid` - The GUID of the application
540    /// * `sandbox_guid` - The GUID of the sandbox
541    ///
542    /// # Returns
543    ///
544    /// A `Result` containing a boolean indicating if the sandbox exists.
545    pub async fn sandbox_exists(
546        &self,
547        application_guid: &str,
548        sandbox_guid: &str,
549    ) -> Result<bool, SandboxError> {
550        match self.get_sandbox(application_guid, sandbox_guid).await {
551            Ok(_) => Ok(true),
552            Err(SandboxError::NotFound) => Ok(false),
553            Err(e) => Err(e),
554        }
555    }
556
557    /// Get sandbox by name
558    ///
559    /// # Arguments
560    ///
561    /// * `application_guid` - The GUID of the application
562    /// * `name` - The name of the sandbox to find
563    ///
564    /// # Returns
565    ///
566    /// A `Result` containing the sandbox if found, or None if not found.
567    pub async fn get_sandbox_by_name(
568        &self,
569        application_guid: &str,
570        name: &str,
571    ) -> Result<Option<Sandbox>, SandboxError> {
572        let params = SandboxListParams {
573            name: Some(name.to_string()),
574            ..Default::default()
575        };
576
577        let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
578        Ok(sandboxes.into_iter().find(|s| s.name == name))
579    }
580
581    /// Validate sandbox creation request
582    fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
583        if request.name.is_empty() {
584            return Err(SandboxError::InvalidInput(
585                "Sandbox name cannot be empty".to_string(),
586            ));
587        }
588        if request.name.len() > 256 {
589            return Err(SandboxError::InvalidInput(
590                "Sandbox name too long (max 256 characters)".to_string(),
591            ));
592        }
593
594        // Check for invalid characters in name
595        if request.name.contains(['<', '>', '"', '&', '\'']) {
596            return Err(SandboxError::InvalidInput(
597                "Sandbox name contains invalid characters".to_string(),
598            ));
599        }
600
601        Ok(())
602    }
603
604    /// Validate sandbox update request
605    fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
606        if let Some(name) = &request.name {
607            if name.is_empty() {
608                return Err(SandboxError::InvalidInput(
609                    "Sandbox name cannot be empty".to_string(),
610                ));
611            }
612            if name.len() > 256 {
613                return Err(SandboxError::InvalidInput(
614                    "Sandbox name too long (max 256 characters)".to_string(),
615                ));
616            }
617
618            // Check for invalid characters in name
619            if name.contains(['<', '>', '"', '&', '\'']) {
620                return Err(SandboxError::InvalidInput(
621                    "Sandbox name contains invalid characters".to_string(),
622                ));
623            }
624        }
625
626        Ok(())
627    }
628}
629
630/// Convenience methods for common sandbox operations
631impl<'a> SandboxApi<'a> {
632    /// Create a simple sandbox with just a name
633    ///
634    /// # Arguments
635    ///
636    /// * `application_guid` - The GUID of the application
637    /// * `name` - The name of the sandbox
638    ///
639    /// # Returns
640    ///
641    /// A `Result` containing the created sandbox or an error.
642    pub async fn create_simple_sandbox(
643        &self,
644        application_guid: &str,
645        name: &str,
646    ) -> Result<Sandbox, SandboxError> {
647        let request = CreateSandboxRequest {
648            name: name.to_string(),
649            description: None,
650            auto_recreate: None,
651            custom_fields: None,
652            team_identifiers: None,
653        };
654
655        self.create_sandbox(application_guid, request).await
656    }
657
658    /// Create a sandbox with auto-recreate enabled
659    ///
660    /// # Arguments
661    ///
662    /// * `application_guid` - The GUID of the application
663    /// * `name` - The name of the sandbox
664    /// * `description` - Optional description
665    ///
666    /// # Returns
667    ///
668    /// A `Result` containing the created sandbox or an error.
669    pub async fn create_auto_recreate_sandbox(
670        &self,
671        application_guid: &str,
672        name: &str,
673        description: Option<String>,
674    ) -> Result<Sandbox, SandboxError> {
675        let request = CreateSandboxRequest {
676            name: name.to_string(),
677            description,
678            auto_recreate: Some(true),
679            custom_fields: None,
680            team_identifiers: None,
681        };
682
683        self.create_sandbox(application_guid, request).await
684    }
685
686    /// Update sandbox name
687    ///
688    /// # Arguments
689    ///
690    /// * `application_guid` - The GUID of the application
691    /// * `sandbox_guid` - The GUID of the sandbox
692    /// * `new_name` - The new name for the sandbox
693    ///
694    /// # Returns
695    ///
696    /// A `Result` containing the updated sandbox or an error.
697    pub async fn update_sandbox_name(
698        &self,
699        application_guid: &str,
700        sandbox_guid: &str,
701        new_name: &str,
702    ) -> Result<Sandbox, SandboxError> {
703        let request = UpdateSandboxRequest {
704            name: Some(new_name.to_string()),
705            description: None,
706            auto_recreate: None,
707            custom_fields: None,
708            team_identifiers: None,
709        };
710
711        self.update_sandbox(application_guid, sandbox_guid, request)
712            .await
713    }
714
715    /// Count sandboxes for an application
716    ///
717    /// # Arguments
718    ///
719    /// * `application_guid` - The GUID of the application
720    ///
721    /// # Returns
722    ///
723    /// A `Result` containing the count of sandboxes or an error.
724    pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
725        let sandboxes = self.list_sandboxes(application_guid, None).await?;
726        Ok(sandboxes.len())
727    }
728
729    /// Get numeric sandbox_id from sandbox GUID.
730    ///
731    /// This is needed for XML API operations that require numeric IDs.
732    ///
733    /// # Arguments
734    ///
735    /// * `application_guid` - The GUID of the application
736    /// * `sandbox_guid` - The sandbox GUID
737    ///
738    /// # Returns
739    ///
740    /// A `Result` containing the numeric sandbox_id as a string.
741    pub async fn get_sandbox_id_from_guid(
742        &self,
743        application_guid: &str,
744        sandbox_guid: &str,
745    ) -> Result<String, SandboxError> {
746        let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
747        match sandbox.id {
748            Some(id) => Ok(id.to_string()),
749            None => Err(SandboxError::InvalidInput(
750                "Sandbox has no numeric ID".to_string(),
751            )),
752        }
753    }
754
755    /// Create sandbox if it doesn't exist, or return existing sandbox.
756    ///
757    /// This method implements the "check and create" pattern commonly needed
758    /// for automated workflows.
759    ///
760    /// # Arguments
761    ///
762    /// * `application_guid` - The GUID of the application
763    /// * `name` - The name of the sandbox
764    /// * `description` - Optional description for new sandboxes
765    ///
766    /// # Returns
767    ///
768    /// A `Result` containing the sandbox (existing or newly created).
769    pub async fn create_sandbox_if_not_exists(
770        &self,
771        application_guid: &str,
772        name: &str,
773        description: Option<String>,
774    ) -> Result<Sandbox, SandboxError> {
775        // First, check if sandbox already exists
776        if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
777            return Ok(existing_sandbox);
778        }
779
780        // Sandbox doesn't exist, create it
781        let create_request = CreateSandboxRequest {
782            name: name.to_string(),
783            description,
784            auto_recreate: Some(true), // Enable auto-recreate by default for CI/CD
785            custom_fields: None,
786            team_identifiers: None,
787        };
788
789        self.create_sandbox(application_guid, create_request).await
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn test_validate_create_request() {
799        // Valid request
800        let valid_request = CreateSandboxRequest {
801            name: "valid-sandbox".to_string(),
802            description: None,
803            auto_recreate: None,
804            custom_fields: None,
805            team_identifiers: None,
806        };
807        assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
808
809        // Empty name
810        let empty_name_request = CreateSandboxRequest {
811            name: String::new(),
812            description: None,
813            auto_recreate: None,
814            custom_fields: None,
815            team_identifiers: None,
816        };
817        assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
818
819        // Long name
820        let long_name_request = CreateSandboxRequest {
821            name: "x".repeat(300),
822            description: None,
823            auto_recreate: None,
824            custom_fields: None,
825            team_identifiers: None,
826        };
827        assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
828
829        // Invalid characters
830        let invalid_char_request = CreateSandboxRequest {
831            name: "invalid<name>".to_string(),
832            description: None,
833            auto_recreate: None,
834            custom_fields: None,
835            team_identifiers: None,
836        };
837        assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
838    }
839
840    #[test]
841    fn test_sandbox_list_params_to_query() {
842        let params = SandboxListParams {
843            name: Some("test".to_string()),
844            page: Some(1),
845            size: Some(10),
846            ..Default::default()
847        };
848
849        let query_params = params.to_query_params();
850        assert_eq!(query_params.len(), 3);
851        assert!(query_params.contains(&("name".to_string(), "test".to_string())));
852        assert!(query_params.contains(&("page".to_string(), "1".to_string())));
853        assert!(query_params.contains(&("size".to_string(), "10".to_string())));
854    }
855
856    #[test]
857    fn test_sandbox_error_display() {
858        let error = SandboxError::NotFound;
859        assert_eq!(error.to_string(), "Sandbox not found");
860
861        let error = SandboxError::InvalidInput("test".to_string());
862        assert_eq!(error.to_string(), "Invalid input: test");
863
864        let error = SandboxError::LimitExceeded;
865        assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
866    }
867}