veracode_platform/
app.rs

1//! Application-specific functionality built on top of the core client.
2//!
3//! This module contains application-specific methods and convenience functions
4//! that use the core `VeracodeClient` to perform application-related operations.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use crate::VeracodeError;
11use crate::client::VeracodeClient;
12use crate::validation::{
13    AppGuid, AppName, Description, ValidationError, build_query_param, validate_page_number,
14    validate_page_size,
15};
16
17/// Represents a Veracode application.
18///
19/// This struct contains all the information about a Veracode application,
20/// including its profile, scans, and metadata.
21#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct Application {
23    /// Globally unique identifier (GUID) for the application
24    pub guid: String,
25    /// Unique numeric identifier for id the application
26    pub id: u64,
27    /// Organization ID
28    pub oid: Option<u64>,
29    /// Organization ID
30    pub alt_org_id: Option<u64>,
31    /// Unique numeric identifier for `organization_id` the application
32    pub organization_id: Option<u64>,
33    /// ISO 8601 timestamp of the last completed scan
34    pub created: String,
35    /// ISO 8601 timestamp when the application was last modified
36    pub modified: Option<String>,
37    /// ISO 8601 timestamp of the last completed scan
38    pub last_completed_scan_date: Option<String>,
39    /// ISO 8601 timestamp of the last policy compliance check
40    pub last_policy_compliance_check_date: Option<String>,
41    /// URL to the application profile in the Veracode platform
42    pub app_profile_url: Option<String>,
43    /// Detailed application profile information
44    pub profile: Option<Profile>,
45    /// List of scans associated with this application
46    pub scans: Option<Vec<Scan>>,
47    /// URL to the application profile in the Veracode platform
48    pub results_url: Option<String>,
49}
50
51/// Application profile information.
52///
53/// # Security
54///
55/// Uses validated types for `name` and `description` to prevent injection attacks
56/// and ensure data meets business requirements.
57#[derive(Debug, Serialize, Deserialize, Clone)]
58pub struct Profile {
59    /// Profile name (validated)
60    pub name: AppName,
61    /// Profile description (validated)
62    pub description: Option<Description>,
63    /// Profile tags
64    pub tags: Option<String>,
65    /// Business unit associated with the application
66    pub business_unit: Option<BusinessUnit>,
67    /// List of business owners
68    pub business_owners: Option<Vec<BusinessOwner>>,
69    /// List of policies applied to the application
70    pub policies: Option<Vec<Policy>>,
71    /// List of teams associated with the application
72    pub teams: Option<Vec<Team>>,
73    /// Archer application name
74    pub archer_app_name: Option<String>,
75    /// Custom fields
76    pub custom_fields: Option<Vec<CustomField>>,
77    /// Business criticality level (required)
78    #[serde(serialize_with = "serialize_business_criticality")]
79    pub business_criticality: BusinessCriticality,
80    /// Application Profile Settings
81    pub settings: Option<Settings>,
82    /// Customer Managed Encryption Key (CMEK) alias for encrypting application data
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub custom_kms_alias: Option<String>,
85    /// Repository URL for the application (e.g., Git repository URL)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub repo_url: Option<String>,
88}
89
90#[derive(Debug, Serialize, Deserialize, Clone)]
91pub struct Settings {
92    /// Profile name
93    pub nextday_consultation_allowed: bool,
94    /// Profile description
95    pub static_scan_xpa_or_dpa: bool,
96    /// Profile tags
97    pub dynamic_scan_approval_not_required: bool,
98    /// Business unit associated with the application
99    pub sca_enabled: bool,
100    /// List of business owners
101    pub static_scan_xpp_enabled: bool,
102}
103
104/// Business unit information.
105#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct BusinessUnit {
107    /// Business unit ID
108    pub id: Option<u64>,
109    /// Business unit name
110    pub name: Option<String>,
111    /// Business unit GUID
112    pub guid: Option<String>,
113}
114
115/// Business owner information.
116///
117/// # Security
118///
119/// This struct contains PII (email, name). The `Debug` implementation
120/// redacts sensitive fields to prevent accidental logging of personal information.
121#[derive(Serialize, Deserialize, Clone)]
122pub struct BusinessOwner {
123    /// Owner's email address
124    pub email: Option<String>,
125    /// Owner's name
126    pub name: Option<String>,
127}
128
129impl fmt::Debug for BusinessOwner {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.debug_struct("BusinessOwner")
132            .field("email", &"[REDACTED]")
133            .field("name", &"[REDACTED]")
134            .finish()
135    }
136}
137
138/// Policy information.
139#[derive(Debug, Serialize, Deserialize, Clone)]
140pub struct Policy {
141    /// Policy GUID
142    pub guid: String,
143    /// Policy name
144    pub name: String,
145    /// Whether this is the default policy
146    pub is_default: bool,
147    /// Policy compliance status
148    pub policy_compliance_status: Option<String>,
149}
150
151/// Team information.
152#[derive(Debug, Serialize, Deserialize, Clone)]
153pub struct Team {
154    /// Team GUID (primary identifier)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub guid: Option<String>,
157    /// Team ID
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub team_id: Option<u64>,
160    /// Team name
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub team_name: Option<String>,
163    /// Legacy team ID
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub team_legacy_id: Option<u64>,
166}
167
168/// Custom field information.
169///
170/// # Security
171///
172/// This struct may contain sensitive data in the `value` field.
173/// The `Debug` implementation redacts the value to prevent accidental
174/// logging of potentially sensitive information.
175#[derive(Serialize, Deserialize, Clone)]
176pub struct CustomField {
177    /// Field name
178    pub name: Option<String>,
179    /// Field value
180    pub value: Option<String>,
181}
182
183impl fmt::Debug for CustomField {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        f.debug_struct("CustomField")
186            .field("name", &self.name)
187            .field("value", &"[REDACTED]")
188            .finish()
189    }
190}
191
192/// Scan information.
193#[derive(Debug, Serialize, Deserialize, Clone)]
194pub struct Scan {
195    /// Scan ID
196    pub scan_id: Option<u64>,
197    /// Type of scan (STATIC, DYNAMIC, etc.)
198    pub scan_type: Option<String>,
199    /// Scan status
200    pub status: Option<String>,
201    /// URL to the scan results
202    pub scan_url: Option<String>,
203    /// When the scan was last modified
204    pub modified_date: Option<String>,
205    /// Internal scan status
206    pub internal_status: Option<String>,
207    /// Related links
208    pub links: Option<Vec<Link>>,
209    /// Fallback scan type
210    pub fallback_type: Option<String>,
211    /// Full scan type
212    pub full_type: Option<String>,
213}
214
215/// Link information.
216#[derive(Debug, Serialize, Deserialize, Clone)]
217pub struct Link {
218    /// Link relationship
219    pub rel: Option<String>,
220    /// Link URL
221    pub href: Option<String>,
222}
223
224/// Response from the Applications API.
225#[derive(Debug, Serialize, Deserialize, Clone)]
226pub struct ApplicationsResponse {
227    /// Embedded applications data
228    #[serde(rename = "_embedded")]
229    pub embedded: Option<EmbeddedApplications>,
230    /// Pagination information
231    pub page: Option<PageInfo>,
232    /// Response links
233    #[serde(rename = "_links")]
234    pub links: Option<HashMap<String, Link>>,
235}
236
237/// Embedded applications in the response.
238#[derive(Debug, Serialize, Deserialize, Clone)]
239pub struct EmbeddedApplications {
240    /// List of applications
241    pub applications: Vec<Application>,
242}
243
244/// Pagination information.
245#[derive(Debug, Serialize, Deserialize, Clone)]
246pub struct PageInfo {
247    /// Number of items per page
248    pub size: Option<u32>,
249    /// Current page number
250    pub number: Option<u32>,
251    /// Total number of elements
252    pub total_elements: Option<u64>,
253    /// Total number of pages
254    pub total_pages: Option<u32>,
255}
256
257/// Request for creating a new application.
258#[derive(Debug, Serialize, Deserialize, Clone)]
259pub struct CreateApplicationRequest {
260    /// Application profile information
261    pub profile: CreateApplicationProfile,
262}
263
264/// Profile information for creating an application.
265///
266/// # Security
267///
268/// Uses validated types for `name` and `description` to ensure data meets
269/// business requirements and prevent injection attacks.
270#[derive(Debug, Serialize, Deserialize, Clone)]
271pub struct CreateApplicationProfile {
272    /// Application name (validated)
273    pub name: AppName,
274    /// Business criticality level (required)
275    #[serde(serialize_with = "serialize_business_criticality")]
276    pub business_criticality: BusinessCriticality,
277    /// Application description (validated)
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub description: Option<Description>,
280    /// Business unit
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub business_unit: Option<BusinessUnit>,
283    /// Business owners
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub business_owners: Option<Vec<BusinessOwner>>,
286    /// Policies
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub policies: Option<Vec<Policy>>,
289    /// Teams
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub teams: Option<Vec<Team>>,
292    /// Tags
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub tags: Option<String>,
295    /// Custom fields
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub custom_fields: Option<Vec<CustomField>>,
298    /// Customer Managed Encryption Key (CMEK) alias for encrypting application data
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub custom_kms_alias: Option<String>,
301    /// Repository URL for the application (e.g., Git repository URL)
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub repo_url: Option<String>,
304}
305
306/// Business criticality levels for applications
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum BusinessCriticality {
309    VeryHigh,
310    High,
311    Medium,
312    Low,
313    VeryLow,
314}
315
316impl BusinessCriticality {
317    /// Convert to the string value expected by the API
318    #[must_use]
319    pub fn as_str(&self) -> &'static str {
320        match self {
321            BusinessCriticality::VeryHigh => "VERY_HIGH",
322            BusinessCriticality::High => "HIGH",
323            BusinessCriticality::Medium => "MEDIUM",
324            BusinessCriticality::Low => "LOW",
325            BusinessCriticality::VeryLow => "VERY_LOW",
326        }
327    }
328}
329
330impl From<BusinessCriticality> for String {
331    fn from(criticality: BusinessCriticality) -> Self {
332        criticality.as_str().to_string()
333    }
334}
335
336impl std::fmt::Display for BusinessCriticality {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        write!(f, "{}", self.as_str())
339    }
340}
341
342/// Custom serializer for `BusinessCriticality`
343fn serialize_business_criticality<S>(
344    criticality: &BusinessCriticality,
345    serializer: S,
346) -> Result<S::Ok, S::Error>
347where
348    S: serde::Serializer,
349{
350    serializer.serialize_str(criticality.as_str())
351}
352
353/// Parse `BusinessCriticality` from string
354impl std::str::FromStr for BusinessCriticality {
355    type Err = String;
356
357    fn from_str(s: &str) -> Result<Self, Self::Err> {
358        match s {
359            "VERY_HIGH" => Ok(BusinessCriticality::VeryHigh),
360            "HIGH" => Ok(BusinessCriticality::High),
361            "MEDIUM" => Ok(BusinessCriticality::Medium),
362            "LOW" => Ok(BusinessCriticality::Low),
363            "VERY_LOW" => Ok(BusinessCriticality::VeryLow),
364            _ => Err(format!(
365                "Invalid business criticality: '{s}'. Must be one of: VERY_HIGH, HIGH, MEDIUM, LOW, VERY_LOW"
366            )),
367        }
368    }
369}
370
371/// Deserialize `BusinessCriticality` from string
372impl<'de> serde::Deserialize<'de> for BusinessCriticality {
373    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
374    where
375        D: serde::Deserializer<'de>,
376    {
377        let s = String::deserialize(deserializer)?;
378        s.parse().map_err(serde::de::Error::custom)
379    }
380}
381
382/// Request for updating an application.
383#[derive(Debug, Serialize, Deserialize, Clone)]
384pub struct UpdateApplicationRequest {
385    /// Application profile information
386    pub profile: UpdateApplicationProfile,
387}
388
389/// Profile information for updating an application.
390///
391/// # Security
392///
393/// Uses validated types for `name` and `description` to ensure data meets
394/// business requirements and prevent injection attacks.
395#[derive(Debug, Serialize, Deserialize, Clone)]
396pub struct UpdateApplicationProfile {
397    /// Application name (validated)
398    pub name: Option<AppName>,
399    /// Application description (validated)
400    pub description: Option<Description>,
401    /// Business unit
402    pub business_unit: Option<BusinessUnit>,
403    /// Business owners
404    pub business_owners: Option<Vec<BusinessOwner>>,
405    /// Business criticality level (required)
406    #[serde(serialize_with = "serialize_business_criticality")]
407    pub business_criticality: BusinessCriticality,
408    /// Policies
409    pub policies: Option<Vec<Policy>>,
410    /// Teams
411    pub teams: Option<Vec<Team>>,
412    /// Tags
413    pub tags: Option<String>,
414    /// Custom fields
415    pub custom_fields: Option<Vec<CustomField>>,
416    /// Customer Managed Encryption Key (CMEK) alias for encrypting application data
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub custom_kms_alias: Option<String>,
419    /// Repository URL for the application (e.g., Git repository URL)
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub repo_url: Option<String>,
422}
423
424/// Query parameters for filtering applications.
425#[derive(Debug, Clone, Default)]
426pub struct ApplicationQuery {
427    /// Filter by application name (partial match)
428    pub name: Option<String>,
429    /// Filter by policy compliance status (PASSED, `DID_NOT_PASS`, etc.)
430    pub policy_compliance: Option<String>,
431    /// Filter applications modified after this date (ISO 8601 format)
432    pub modified_after: Option<String>,
433    /// Filter applications modified before this date (ISO 8601 format)
434    pub modified_before: Option<String>,
435    /// Filter applications created after this date (ISO 8601 format)
436    pub created_after: Option<String>,
437    /// Filter applications created before this date (ISO 8601 format)
438    pub created_before: Option<String>,
439    /// Filter by scan type (STATIC, DYNAMIC, MANUAL, SCA)
440    pub scan_type: Option<String>,
441    /// Filter by tags (comma-separated)
442    pub tags: Option<String>,
443    /// Filter by business unit name
444    pub business_unit: Option<String>,
445    /// Page number for pagination (0-based)
446    pub page: Option<u32>,
447    /// Number of items per page (default: 20, max: 500)
448    pub size: Option<u32>,
449}
450
451impl ApplicationQuery {
452    /// Create a new empty query.
453    #[must_use = "builder methods consume self and return modified Self"]
454    pub fn new() -> Self {
455        ApplicationQuery::default()
456    }
457
458    /// Filter applications by name (partial match).
459    #[must_use = "builder methods consume self and return modified Self"]
460    pub fn with_name(mut self, name: &str) -> Self {
461        self.name = Some(name.to_string());
462        self
463    }
464
465    /// Filter applications by policy compliance status.
466    #[must_use = "builder methods consume self and return modified Self"]
467    pub fn with_policy_compliance(mut self, compliance: &str) -> Self {
468        self.policy_compliance = Some(compliance.to_string());
469        self
470    }
471
472    /// Filter applications modified after the specified date.
473    #[must_use = "builder methods consume self and return modified Self"]
474    pub fn with_modified_after(mut self, date: &str) -> Self {
475        self.modified_after = Some(date.to_string());
476        self
477    }
478
479    /// Filter applications modified before the specified date.
480    #[must_use = "builder methods consume self and return modified Self"]
481    pub fn with_modified_before(mut self, date: &str) -> Self {
482        self.modified_before = Some(date.to_string());
483        self
484    }
485
486    /// Set the page number for pagination.
487    #[must_use]
488    pub fn with_page(mut self, page: u32) -> Self {
489        self.page = Some(page);
490        self
491    }
492
493    /// Set the number of items per page.
494    #[must_use]
495    pub fn with_size(mut self, size: u32) -> Self {
496        self.size = Some(size);
497        self
498    }
499
500    /// Normalize and validate pagination parameters.
501    ///
502    /// This method ensures that pagination parameters are within safe bounds
503    /// to prevent resource exhaustion attacks. It uses the library-wide
504    /// validation functions from the `validation` module.
505    ///
506    /// # Behavior
507    ///
508    /// - Sets default page size if not specified
509    /// - Rejects page size of 0
510    /// - Caps page size at maximum with warning
511    /// - Caps page number at maximum with warning
512    ///
513    /// # Returns
514    ///
515    /// A `Result` containing the normalized query or a `ValidationError`.
516    ///
517    /// # Errors
518    ///
519    /// Returns an error if the page size is 0.
520    ///
521    /// # Security
522    ///
523    /// This method prevents `DoS` attacks from unbounded pagination requests.
524    pub fn normalize(mut self) -> Result<Self, ValidationError> {
525        // Validate and normalize using library-wide validation functions
526        self.size = Some(validate_page_size(self.size)?);
527        self.page = validate_page_number(self.page)?;
528
529        Ok(self)
530    }
531
532    /// Convert the query to URL query parameters.
533    #[must_use]
534    pub fn to_query_params(&self) -> Vec<(String, String)> {
535        Vec::from(self)
536    }
537}
538
539/// Convert `ApplicationQuery` to query parameters by borrowing (allows reuse)
540///
541/// # Security
542///
543/// All query parameter values are properly URL-encoded to prevent injection attacks.
544impl From<&ApplicationQuery> for Vec<(String, String)> {
545    fn from(query: &ApplicationQuery) -> Self {
546        let mut params = Vec::new();
547
548        if let Some(ref name) = query.name {
549            params.push(build_query_param("name", name));
550        }
551        if let Some(ref compliance) = query.policy_compliance {
552            params.push(build_query_param("policy_compliance", compliance));
553        }
554        if let Some(ref date) = query.modified_after {
555            params.push(build_query_param("modified_after", date));
556        }
557        if let Some(ref date) = query.modified_before {
558            params.push(build_query_param("modified_before", date));
559        }
560        if let Some(ref date) = query.created_after {
561            params.push(build_query_param("created_after", date));
562        }
563        if let Some(ref date) = query.created_before {
564            params.push(build_query_param("created_before", date));
565        }
566        if let Some(ref scan_type) = query.scan_type {
567            params.push(build_query_param("scan_type", scan_type));
568        }
569        if let Some(ref tags) = query.tags {
570            params.push(build_query_param("tags", tags));
571        }
572        if let Some(ref business_unit) = query.business_unit {
573            params.push(build_query_param("business_unit", business_unit));
574        }
575        if let Some(page) = query.page {
576            params.push(("page".to_string(), page.to_string()));
577        }
578        if let Some(size) = query.size {
579            params.push(("size".to_string(), size.to_string()));
580        }
581
582        params
583    }
584}
585
586/// Convert `ApplicationQuery` to query parameters by consuming (better performance)
587///
588/// # Security
589///
590/// All query parameter values are properly URL-encoded to prevent injection attacks.
591impl From<ApplicationQuery> for Vec<(String, String)> {
592    fn from(query: ApplicationQuery) -> Self {
593        let mut params = Vec::new();
594
595        if let Some(name) = query.name {
596            params.push(build_query_param("name", &name));
597        }
598        if let Some(compliance) = query.policy_compliance {
599            params.push(build_query_param("policy_compliance", &compliance));
600        }
601        if let Some(date) = query.modified_after {
602            params.push(build_query_param("modified_after", &date));
603        }
604        if let Some(date) = query.modified_before {
605            params.push(build_query_param("modified_before", &date));
606        }
607        if let Some(date) = query.created_after {
608            params.push(build_query_param("created_after", &date));
609        }
610        if let Some(date) = query.created_before {
611            params.push(build_query_param("created_before", &date));
612        }
613        if let Some(scan_type) = query.scan_type {
614            params.push(build_query_param("scan_type", &scan_type));
615        }
616        if let Some(tags) = query.tags {
617            params.push(build_query_param("tags", &tags));
618        }
619        if let Some(business_unit) = query.business_unit {
620            params.push(build_query_param("business_unit", &business_unit));
621        }
622        if let Some(page) = query.page {
623            params.push(("page".to_string(), page.to_string()));
624        }
625        if let Some(size) = query.size {
626            params.push(("size".to_string(), size.to_string()));
627        }
628
629        params
630    }
631}
632
633/// Application-specific methods that build on the core client.
634impl VeracodeClient {
635    /// Get all applications with optional filtering.
636    ///
637    /// # Arguments
638    ///
639    /// * `query` - Optional query parameters to filter the results
640    ///
641    /// # Returns
642    ///
643    /// A `Result` containing an `ApplicationsResponse` with the list of applications.
644    ///
645    /// # Errors
646    ///
647    /// Returns an error if the API request fails, the response cannot be parsed,
648    /// or validation of pagination parameters fails.
649    ///
650    /// # Security
651    ///
652    /// Pagination parameters are automatically validated and normalized to prevent
653    /// resource exhaustion attacks.
654    pub async fn get_applications(
655        &self,
656        query: Option<ApplicationQuery>,
657    ) -> Result<ApplicationsResponse, VeracodeError> {
658        let endpoint = "/appsec/v1/applications";
659
660        // Normalize query parameters to enforce pagination bounds
661        let normalized_query = if let Some(q) = query {
662            Some(q.normalize()?)
663        } else {
664            None
665        };
666
667        let query_params = normalized_query.as_ref().map(Vec::from);
668
669        let response = self.get(endpoint, query_params.as_deref()).await?;
670        let response = Self::handle_response(response, "list applications").await?;
671
672        let apps_response: ApplicationsResponse = response.json().await?;
673        Ok(apps_response)
674    }
675
676    /// Get a specific application by its GUID.
677    ///
678    /// # Arguments
679    ///
680    /// * `guid` - The GUID of the application to retrieve
681    ///
682    /// # Returns
683    ///
684    /// A `Result` containing the `Application` details.
685    ///
686    /// # Errors
687    ///
688    /// Returns an error if the API request fails, the response cannot be parsed,
689    /// or the application is not found.
690    ///
691    /// # Security
692    ///
693    /// This method validates the GUID format to prevent URL path injection attacks.
694    pub async fn get_application(&self, guid: &AppGuid) -> Result<Application, VeracodeError> {
695        // AppGuid is already validated, safe to use in URL
696        let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
697
698        let response = self.get(&endpoint, None).await?;
699        let response = Self::handle_response(response, "get application details").await?;
700
701        let app: Application = response.json().await?;
702        Ok(app)
703    }
704
705    /// Create a new application.
706    ///
707    /// # Arguments
708    ///
709    /// * `request` - The application creation request containing profile information
710    ///
711    /// # Returns
712    ///
713    /// A `Result` containing the created `Application`.
714    ///
715    /// # Errors
716    ///
717    /// Returns an error if the API request fails, the request cannot be serialized,
718    /// or the response cannot be parsed.
719    pub async fn create_application(
720        &self,
721        request: &CreateApplicationRequest,
722    ) -> Result<Application, VeracodeError> {
723        let endpoint = "/appsec/v1/applications";
724
725        // Debug: Log the exact JSON being sent to the API
726        if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
727            log::debug!(
728                "🔍 Creating application with JSON payload: {}",
729                json_payload
730            );
731        }
732
733        let response = self.post(endpoint, Some(&request)).await?;
734        let response = Self::handle_response(response, "create application").await?;
735
736        let app: Application = response.json().await?;
737        Ok(app)
738    }
739
740    /// Update an existing application.
741    ///
742    /// # Arguments
743    ///
744    /// * `guid` - The GUID of the application to update
745    /// * `request` - The update request containing the new profile information
746    ///
747    /// # Returns
748    ///
749    /// A `Result` containing the updated `Application`.
750    ///
751    /// # Errors
752    ///
753    /// Returns an error if the API request fails, the request cannot be serialized,
754    /// or the response cannot be parsed.
755    ///
756    /// # Security
757    ///
758    /// This method validates the GUID format to prevent URL path injection attacks.
759    pub async fn update_application(
760        &self,
761        guid: &AppGuid,
762        request: &UpdateApplicationRequest,
763    ) -> Result<Application, VeracodeError> {
764        // AppGuid is already validated, safe to use in URL
765        let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
766
767        let response = self.put(&endpoint, Some(&request)).await?;
768        let response = Self::handle_response(response, "update application").await?;
769
770        let app: Application = response.json().await?;
771        Ok(app)
772    }
773
774    /// Delete an application.
775    ///
776    /// # Arguments
777    ///
778    /// * `guid` - The GUID of the application to delete
779    ///
780    /// # Returns
781    ///
782    /// A `Result` indicating success or failure.
783    ///
784    /// # Errors
785    ///
786    /// Returns an error if the API request fails or the application is not found.
787    ///
788    /// # Security
789    ///
790    /// This method validates the GUID format to prevent URL path injection attacks.
791    pub async fn delete_application(&self, guid: &AppGuid) -> Result<(), VeracodeError> {
792        // AppGuid is already validated, safe to use in URL
793        let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
794
795        let response = self.delete(&endpoint).await?;
796        let _response = Self::handle_response(response, "delete application").await?;
797
798        Ok(())
799    }
800
801    /// Get applications that failed policy compliance.
802    ///
803    /// # Returns
804    ///
805    /// A `Result` containing a `Vec<Application>` of non-compliant applications.
806    ///
807    /// # Errors
808    ///
809    /// Returns an error if the API request fails or the response cannot be parsed.
810    pub async fn get_non_compliant_applications(&self) -> Result<Vec<Application>, VeracodeError> {
811        let query = ApplicationQuery::new().with_policy_compliance("DID_NOT_PASS");
812
813        let response = self.get_applications(Some(query)).await?;
814
815        if let Some(embedded) = response.embedded {
816            Ok(embedded.applications)
817        } else {
818            Ok(Vec::new())
819        }
820    }
821
822    /// Get applications modified after a specific date.
823    ///
824    /// # Arguments
825    ///
826    /// * `date` - ISO 8601 formatted date string
827    ///
828    /// # Returns
829    ///
830    /// A `Result` containing a `Vec<Application>` of applications modified after the date.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the API request fails or the response cannot be parsed.
835    pub async fn get_applications_modified_after(
836        &self,
837        date: &str,
838    ) -> Result<Vec<Application>, VeracodeError> {
839        let query = ApplicationQuery::new().with_modified_after(date);
840
841        let response = self.get_applications(Some(query)).await?;
842
843        if let Some(embedded) = response.embedded {
844            Ok(embedded.applications)
845        } else {
846            Ok(Vec::new())
847        }
848    }
849
850    /// Search applications by name.
851    ///
852    /// # Arguments
853    ///
854    /// * `name` - The name to search for (partial matches are supported)
855    ///
856    /// # Returns
857    ///
858    /// A `Result` containing a `Vec<Application>` of applications matching the name.
859    ///
860    /// # Errors
861    ///
862    /// Returns an error if the API request fails or the response cannot be parsed.
863    pub async fn search_applications_by_name(
864        &self,
865        name: &str,
866    ) -> Result<Vec<Application>, VeracodeError> {
867        let query = ApplicationQuery::new().with_name(name);
868
869        let response = self.get_applications(Some(query)).await?;
870
871        if let Some(embedded) = response.embedded {
872            Ok(embedded.applications)
873        } else {
874            Ok(Vec::new())
875        }
876    }
877
878    /// Get all applications with automatic pagination.
879    ///
880    /// # Returns
881    ///
882    /// A `Result` containing a `Vec<Application>` of all applications.
883    ///
884    /// # Errors
885    ///
886    /// Returns an error if any API request fails or responses cannot be parsed.
887    pub async fn get_all_applications(&self) -> Result<Vec<Application>, VeracodeError> {
888        let mut all_applications = Vec::new();
889        let mut page = 0;
890
891        loop {
892            let query = ApplicationQuery::new().with_page(page).with_size(100);
893
894            let response = self.get_applications(Some(query)).await?;
895
896            if let Some(embedded) = response.embedded {
897                if embedded.applications.is_empty() {
898                    break;
899                }
900                all_applications.extend(embedded.applications);
901                page = page.saturating_add(1);
902            } else {
903                break;
904            }
905        }
906
907        Ok(all_applications)
908    }
909
910    /// Get application by name (exact match).
911    ///
912    /// # Arguments
913    ///
914    /// * `name` - The exact name of the application to find
915    ///
916    /// # Returns
917    ///
918    /// A `Result` containing an `Option<Application>` if found.
919    ///
920    /// # Errors
921    ///
922    /// Returns an error if the API request fails or the response cannot be parsed.
923    pub async fn get_application_by_name(
924        &self,
925        name: &str,
926    ) -> Result<Option<Application>, VeracodeError> {
927        let applications = self.search_applications_by_name(name).await?;
928
929        // Find exact match (search_applications_by_name does partial matching)
930        Ok(applications.into_iter().find(|app| {
931            if let Some(profile) = &app.profile {
932                profile.name.as_str() == name
933            } else {
934                false
935            }
936        }))
937    }
938
939    /// Check if application exists by name.
940    ///
941    /// # Arguments
942    ///
943    /// * `name` - The name of the application to check
944    ///
945    /// # Returns
946    ///
947    /// A `Result` containing a boolean indicating if the application exists.
948    ///
949    /// # Errors
950    ///
951    /// Returns an error if the API request fails or the response cannot be parsed.
952    pub async fn application_exists_by_name(&self, name: &str) -> Result<bool, VeracodeError> {
953        match self.get_application_by_name(name).await? {
954            Some(_) => Ok(true),
955            None => Ok(false),
956        }
957    }
958
959    /// Get numeric `app_id` from application GUID.
960    ///
961    /// This is needed for XML API operations that require numeric IDs.
962    ///
963    /// # Arguments
964    ///
965    /// * `guid` - The application GUID
966    ///
967    /// # Returns
968    ///
969    /// A `Result` containing the numeric `app_id` as a string.
970    ///
971    /// # Errors
972    ///
973    /// Returns an error if the API request fails, the application is not found,
974    /// or the response cannot be parsed.
975    pub async fn get_app_id_from_guid(&self, guid: &AppGuid) -> Result<String, VeracodeError> {
976        let app = self.get_application(guid).await?;
977        Ok(app.id.to_string())
978    }
979
980    /// Create application if it doesn't exist, or return existing application.
981    ///
982    /// This method implements the "check and create" pattern commonly needed
983    /// for automated workflows. It intelligently updates missing fields on existing
984    /// applications without overriding any existing values.
985    ///
986    /// # Behavior
987    ///
988    /// - **If application doesn't exist**: Creates it with all provided parameters
989    /// - **If application exists**:
990    ///   - Updates `repo_url` if current value is None/empty and parameter is provided
991    ///   - Updates `description` if current value is None/empty and parameter is provided
992    ///   - Never modifies `business_criticality` or `teams` on existing applications
993    ///   - All other existing profile settings are preserved
994    ///
995    /// This "fill in blanks" strategy ensures safe automation without overriding
996    /// intentional configuration changes made through other workflows.
997    ///
998    /// # Arguments
999    ///
1000    /// * `name` - The name of the application
1001    /// * `business_criticality` - Business criticality level (required for creation, ignored for existing apps)
1002    /// * `description` - Optional description (sets on creation, updates if missing on existing apps)
1003    /// * `team_names` - Optional list of team names (sets on creation, ignored for existing apps)
1004    /// * `repo_url` - Optional repository URL (sets on creation, updates if missing on existing apps)
1005    /// * `custom_kms_alias` - Optional KMS alias for encryption
1006    ///
1007    /// # Returns
1008    ///
1009    /// A `Result` containing the application (existing, updated, or newly created).
1010    ///
1011    /// # Errors
1012    ///
1013    /// Returns an error if the API request fails, validation fails, team lookup fails,
1014    /// or the response cannot be parsed.
1015    ///
1016    /// # Examples
1017    ///
1018    /// ```no_run
1019    /// # use veracode_platform::{VeracodeClient, VeracodeConfig, app::BusinessCriticality};
1020    /// # use std::sync::Arc;
1021    /// # use secrecy::SecretString;
1022    /// # #[tokio::main]
1023    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1024    /// # let config = VeracodeConfig::from_arc_credentials(
1025    /// #     Arc::new(SecretString::from("api_id")),
1026    /// #     Arc::new(SecretString::from("api_key"))
1027    /// # );
1028    /// # let client = VeracodeClient::new(config)?;
1029    /// // First call: Creates application with repo_url
1030    /// let app = client.create_application_if_not_exists(
1031    ///     "My Application",
1032    ///     BusinessCriticality::High,
1033    ///     Some("My app description".to_string()),
1034    ///     None,
1035    ///     Some("https://github.com/user/repo".to_string()),
1036    ///     None,
1037    /// ).await?;
1038    ///
1039    /// // Second call: Returns existing app, no updates (all fields populated)
1040    /// let same_app = client.create_application_if_not_exists(
1041    ///     "My Application",
1042    ///     BusinessCriticality::Medium, // Ignored - won't change existing HIGH
1043    ///     Some("Different description".to_string()), // Ignored - existing has value
1044    ///     None,
1045    ///     Some("https://github.com/user/repo".to_string()), // Ignored - existing has value
1046    ///     None,
1047    /// ).await?;
1048    ///
1049    /// // Application created without repo_url, then updated later
1050    /// let app_v1 = client.create_application_if_not_exists(
1051    ///     "Another App",
1052    ///     BusinessCriticality::Medium,
1053    ///     None,
1054    ///     None,
1055    ///     None, // No repo_url initially
1056    ///     None,
1057    /// ).await?;
1058    ///
1059    /// // Later: Adds repo_url to existing application (because it was missing)
1060    /// let app_v2 = client.create_application_if_not_exists(
1061    ///     "Another App",
1062    ///     BusinessCriticality::High, // Ignored - won't change
1063    ///     Some("Adding description".to_string()), // Updates (was None)
1064    ///     None,
1065    ///     Some("https://github.com/user/another".to_string()), // Updates (was None)
1066    ///     None,
1067    /// ).await?;
1068    /// # Ok(())
1069    /// # }
1070    /// ```
1071    pub async fn create_application_if_not_exists(
1072        &self,
1073        name: &str,
1074        business_criticality: BusinessCriticality,
1075        description: Option<String>,
1076        team_names: Option<Vec<String>>,
1077        repo_url: Option<String>,
1078        custom_kms_alias: Option<String>,
1079    ) -> Result<Application, VeracodeError> {
1080        // First, check if application already exists
1081        if let Some(existing_app) = self.get_application_by_name(name).await? {
1082            // Check if we need to update any missing fields
1083            let mut needs_update = false;
1084            let mut update_repo_url = false;
1085            let mut update_description = false;
1086            let mut update_custom_kms_alias = false;
1087
1088            if let Some(ref profile) = existing_app.profile {
1089                // Check repo_url: update if we have one AND existing is None/empty
1090                if repo_url.is_some()
1091                    && (profile.repo_url.is_none()
1092                        || profile
1093                            .repo_url
1094                            .as_ref()
1095                            .is_some_and(|u| u.trim().is_empty()))
1096                {
1097                    update_repo_url = true;
1098                    needs_update = true;
1099                }
1100
1101                // Check description: update if we have one AND existing is None/empty
1102                if description.is_some()
1103                    && (profile.description.is_none()
1104                        || profile
1105                            .description
1106                            .as_ref()
1107                            .is_some_and(|d| d.as_str().trim().is_empty()))
1108                {
1109                    update_description = true;
1110                    needs_update = true;
1111                }
1112
1113                // Check custom_kms_alias: update if we have one AND existing is None/empty
1114                if custom_kms_alias.is_some()
1115                    && (profile.custom_kms_alias.is_none()
1116                        || profile
1117                            .custom_kms_alias
1118                            .as_ref()
1119                            .is_some_and(|k| k.trim().is_empty()))
1120                {
1121                    update_custom_kms_alias = true;
1122                    needs_update = true;
1123                }
1124            }
1125
1126            if needs_update {
1127                log::debug!("🔄 Updating fields for existing application '{}'", name);
1128                if update_repo_url {
1129                    log::debug!(
1130                        "   Setting repo_url: {}",
1131                        repo_url.as_deref().unwrap_or("None")
1132                    );
1133                }
1134                if update_description {
1135                    log::debug!(
1136                        "   Setting description: {}",
1137                        description.as_deref().unwrap_or("None")
1138                    );
1139                }
1140                if update_custom_kms_alias {
1141                    log::debug!(
1142                        "   Setting custom_kms_alias: {}",
1143                        custom_kms_alias.as_deref().unwrap_or("None")
1144                    );
1145                }
1146
1147                // Build update request preserving all existing values
1148                let profile = existing_app.profile.as_ref().ok_or_else(|| {
1149                    VeracodeError::InvalidResponse(format!("Application '{}' has no profile", name))
1150                })?;
1151                let update_request = UpdateApplicationRequest {
1152                    profile: UpdateApplicationProfile {
1153                        name: Some(profile.name.clone()),
1154                        description: if update_description {
1155                            description.map(Description::new).transpose()?
1156                        } else {
1157                            profile.description.clone()
1158                        },
1159                        business_unit: profile.business_unit.clone(),
1160                        business_owners: profile.business_owners.clone(),
1161                        business_criticality: profile.business_criticality, // Keep existing
1162                        policies: profile.policies.clone(),
1163                        teams: profile.teams.clone(), // Keep existing
1164                        tags: profile.tags.clone(),
1165                        custom_fields: profile.custom_fields.clone(),
1166                        custom_kms_alias: if update_custom_kms_alias {
1167                            custom_kms_alias
1168                        } else {
1169                            profile.custom_kms_alias.clone()
1170                        },
1171                        repo_url: if update_repo_url {
1172                            repo_url
1173                        } else {
1174                            profile.repo_url.clone()
1175                        },
1176                    },
1177                };
1178
1179                let guid = AppGuid::new(&existing_app.guid)?;
1180                return self.update_application(&guid, &update_request).await;
1181            }
1182
1183            return Ok(existing_app);
1184        }
1185
1186        // Application doesn't exist, create it
1187
1188        // Convert team names to Team objects with GUIDs if provided
1189        let teams = if let Some(names) = team_names {
1190            let identity_api = self.identity_api();
1191            let mut resolved_teams = Vec::new();
1192
1193            for team_name in names {
1194                match identity_api.get_team_guid_by_name(&team_name).await {
1195                    Ok(Some(team_guid)) => {
1196                        resolved_teams.push(Team {
1197                            guid: Some(team_guid),
1198                            team_id: None,
1199                            team_name: None, // Not needed when using GUID
1200                            team_legacy_id: None,
1201                        });
1202                    }
1203                    Ok(None) => {
1204                        return Err(VeracodeError::NotFound(format!(
1205                            "Team '{}' not found",
1206                            team_name
1207                        )));
1208                    }
1209                    Err(identity_err) => {
1210                        return Err(VeracodeError::InvalidResponse(format!(
1211                            "Failed to lookup team '{}': {}",
1212                            team_name, identity_err
1213                        )));
1214                    }
1215                }
1216            }
1217
1218            Some(resolved_teams)
1219        } else {
1220            None
1221        };
1222
1223        let create_request = CreateApplicationRequest {
1224            profile: CreateApplicationProfile {
1225                name: AppName::new(name)?,
1226                business_criticality,
1227                description: description.map(Description::new).transpose()?,
1228                business_unit: None,
1229                business_owners: None,
1230                policies: None,
1231                teams,
1232                tags: None,
1233                custom_fields: None,
1234                custom_kms_alias,
1235                repo_url,
1236            },
1237        };
1238
1239        self.create_application(&create_request).await
1240    }
1241
1242    /// Create application if it doesn't exist, or return existing application (with team GUIDs).
1243    ///
1244    /// This method allows specifying teams by their GUID, which is the preferred
1245    /// approach for programmatic application creation.
1246    ///
1247    /// # Arguments
1248    ///
1249    /// * `name` - The name of the application
1250    /// * `business_criticality` - Business criticality level (required for creation)
1251    /// * `description` - Optional description for new applications
1252    /// * `team_guids` - Optional list of team GUIDs to assign to the application
1253    ///
1254    /// # Returns
1255    ///
1256    /// A `Result` containing the application (existing or newly created).
1257    ///
1258    /// # Errors
1259    ///
1260    /// Returns an error if the API request fails, validation fails,
1261    /// or the response cannot be parsed.
1262    pub async fn create_application_if_not_exists_with_team_guids(
1263        &self,
1264        name: &str,
1265        business_criticality: BusinessCriticality,
1266        description: Option<String>,
1267        team_guids: Option<Vec<String>>,
1268    ) -> Result<Application, VeracodeError> {
1269        // First, check if application already exists
1270        if let Some(existing_app) = self.get_application_by_name(name).await? {
1271            return Ok(existing_app);
1272        }
1273
1274        // Application doesn't exist, create it
1275
1276        // Convert team GUIDs to Team objects if provided
1277        let teams = team_guids.map(|guids| {
1278            guids
1279                .into_iter()
1280                .map(|team_guid| Team {
1281                    guid: Some(team_guid),
1282                    team_id: None,        // Will be assigned by Veracode
1283                    team_name: None,      // Not needed when using GUID
1284                    team_legacy_id: None, // Will be assigned by Veracode
1285                })
1286                .collect()
1287        });
1288
1289        let create_request = CreateApplicationRequest {
1290            profile: CreateApplicationProfile {
1291                name: AppName::new(name)?,
1292                business_criticality,
1293                description: description.map(Description::new).transpose()?,
1294                business_unit: None,
1295                business_owners: None,
1296                policies: None,
1297                teams,
1298                tags: None,
1299                custom_fields: None,
1300                custom_kms_alias: None,
1301                repo_url: None,
1302            },
1303        };
1304
1305        self.create_application(&create_request).await
1306    }
1307
1308    /// Create application if it doesn't exist, or return existing application (without teams).
1309    ///
1310    /// This is a convenience method that maintains backward compatibility
1311    /// for callers that don't need to specify teams.
1312    ///
1313    /// # Arguments
1314    ///
1315    /// * `name` - The name of the application
1316    /// * `business_criticality` - Business criticality level (required for creation)
1317    /// * `description` - Optional description for new applications
1318    ///
1319    /// # Returns
1320    ///
1321    /// A `Result` containing the application (existing or newly created).
1322    ///
1323    /// # Errors
1324    ///
1325    /// Returns an error if the API request fails, validation fails,
1326    /// or the response cannot be parsed.
1327    pub async fn create_application_if_not_exists_simple(
1328        &self,
1329        name: &str,
1330        business_criticality: BusinessCriticality,
1331        description: Option<String>,
1332    ) -> Result<Application, VeracodeError> {
1333        self.create_application_if_not_exists(
1334            name,
1335            business_criticality,
1336            description,
1337            None,
1338            None,
1339            None,
1340        )
1341        .await
1342    }
1343
1344    /// Enable Customer Managed Encryption Key (CMEK) on an application
1345    ///
1346    /// This method updates an existing application to use a customer-managed encryption key.
1347    /// The KMS alias must be properly formatted and the key must be accessible to Veracode.
1348    ///
1349    /// # Arguments
1350    ///
1351    /// * `app_guid` - The GUID of the application to enable encryption on
1352    /// * `kms_alias` - The AWS KMS alias to use for encryption (must start with "alias/")
1353    ///
1354    /// # Returns
1355    ///
1356    /// A `Result` containing the updated application or an error.
1357    ///
1358    /// # Errors
1359    ///
1360    /// Returns an error if the KMS alias format is invalid, the API request fails,
1361    /// the application is not found, or the response cannot be parsed.
1362    ///
1363    /// # Examples
1364    ///
1365    /// ```no_run
1366    /// # use veracode_platform::{VeracodeClient, VeracodeConfig, AppGuid};
1367    /// # use std::sync::Arc;
1368    /// # use secrecy::SecretString;
1369    /// # #[tokio::main]
1370    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1371    /// let config = VeracodeConfig::from_arc_credentials(
1372    ///     Arc::new(SecretString::from("api_id")),
1373    ///     Arc::new(SecretString::from("api_key"))
1374    /// );
1375    /// let client = VeracodeClient::new(config)?;
1376    /// let guid = AppGuid::new("550e8400-e29b-41d4-a716-446655440000")?;
1377    ///
1378    /// let app = client.enable_application_encryption(
1379    ///     &guid,
1380    ///     "alias/my-encryption-key"
1381    /// ).await?;
1382    /// # Ok(())
1383    /// # }
1384    /// ```
1385    pub async fn enable_application_encryption(
1386        &self,
1387        app_guid: &AppGuid,
1388        kms_alias: &str,
1389    ) -> Result<Application, VeracodeError> {
1390        // Validate KMS alias format
1391        validate_kms_alias(kms_alias).map_err(VeracodeError::InvalidConfig)?;
1392
1393        // Get current application to preserve existing settings
1394        let current_app = self.get_application(app_guid).await?;
1395
1396        let profile = current_app
1397            .profile
1398            .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1399
1400        // Create update request with CMEK enabled
1401        let update_request = UpdateApplicationRequest {
1402            profile: UpdateApplicationProfile {
1403                name: Some(profile.name),
1404                description: profile.description,
1405                business_unit: profile.business_unit,
1406                business_owners: profile.business_owners,
1407                business_criticality: profile.business_criticality,
1408                policies: profile.policies,
1409                teams: profile.teams,
1410                tags: profile.tags,
1411                custom_fields: profile.custom_fields,
1412                custom_kms_alias: Some(kms_alias.to_string()),
1413                repo_url: profile.repo_url,
1414            },
1415        };
1416
1417        self.update_application(app_guid, &update_request).await
1418    }
1419
1420    /// Change the encryption key for an application with CMEK enabled
1421    ///
1422    /// This method updates the KMS alias used for encrypting an application's data.
1423    /// The application must already have CMEK enabled.
1424    ///
1425    /// # Arguments
1426    ///
1427    /// * `app_guid` - The GUID of the application to update
1428    /// * `new_kms_alias` - The new AWS KMS alias to use for encryption
1429    ///
1430    /// # Returns
1431    ///
1432    /// A `Result` containing the updated application or an error.
1433    ///
1434    /// # Errors
1435    ///
1436    /// Returns an error if the KMS alias format is invalid, the API request fails,
1437    /// the application is not found, or the response cannot be parsed.
1438    pub async fn change_encryption_key(
1439        &self,
1440        app_guid: &AppGuid,
1441        new_kms_alias: &str,
1442    ) -> Result<Application, VeracodeError> {
1443        // Validate new KMS alias format
1444        validate_kms_alias(new_kms_alias).map_err(VeracodeError::InvalidConfig)?;
1445
1446        // Get current application
1447        let current_app = self.get_application(app_guid).await?;
1448
1449        let profile = current_app
1450            .profile
1451            .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1452
1453        // Create update request with new KMS alias
1454        let update_request = UpdateApplicationRequest {
1455            profile: UpdateApplicationProfile {
1456                name: Some(profile.name),
1457                description: profile.description,
1458                business_unit: profile.business_unit,
1459                business_owners: profile.business_owners,
1460                business_criticality: profile.business_criticality,
1461                policies: profile.policies,
1462                teams: profile.teams,
1463                tags: profile.tags,
1464                custom_fields: profile.custom_fields,
1465                custom_kms_alias: Some(new_kms_alias.to_string()),
1466                repo_url: profile.repo_url,
1467            },
1468        };
1469
1470        self.update_application(app_guid, &update_request).await
1471    }
1472
1473    /// Get the encryption status of an application
1474    ///
1475    /// This method retrieves the current CMEK configuration for an application.
1476    ///
1477    /// # Arguments
1478    ///
1479    /// * `app_guid` - The GUID of the application to check
1480    ///
1481    /// # Returns
1482    ///
1483    /// A `Result` containing the KMS alias if CMEK is enabled, None if disabled, or an error.
1484    ///
1485    /// # Errors
1486    ///
1487    /// Returns an error if the API request fails, the application is not found,
1488    /// or the response cannot be parsed.
1489    pub async fn get_application_encryption_status(
1490        &self,
1491        app_guid: &AppGuid,
1492    ) -> Result<Option<String>, VeracodeError> {
1493        let app = self.get_application(app_guid).await?;
1494
1495        // CMEK is stored directly in the profile as custom_kms_alias, not in custom_fields
1496        Ok(app.profile.and_then(|profile| profile.custom_kms_alias))
1497    }
1498}
1499
1500/// Validates an AWS KMS alias format
1501///
1502/// AWS KMS aliases must follow specific naming conventions:
1503/// - Must be prefixed with "alias/"
1504/// - Total length must be between 8-256 characters
1505/// - Can contain alphanumeric characters, hyphens, underscores, and forward slashes
1506/// - Cannot begin or end with "aws" (reserved by AWS)
1507///
1508/// # Examples
1509///
1510/// ```
1511/// use veracode_platform::app::validate_kms_alias;
1512///
1513/// assert!(validate_kms_alias("alias/my-app-key").is_ok());
1514/// assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1515/// assert!(validate_kms_alias("invalid-alias").is_err());
1516/// assert!(validate_kms_alias("alias/aws-managed").is_err());
1517/// ```
1518///
1519/// # Errors
1520///
1521/// Returns an error if the alias doesn't meet AWS KMS naming requirements.
1522pub fn validate_kms_alias(alias: &str) -> Result<(), String> {
1523    // Check prefix
1524    if !alias.starts_with("alias/") {
1525        return Err("KMS alias must start with 'alias/'".to_string());
1526    }
1527
1528    // Check length (including the "alias/" prefix) - minimum 8 characters for meaningful alias
1529    if alias.len() < 8 || alias.len() > 256 {
1530        return Err("KMS alias must be between 8 and 256 characters long".to_string());
1531    }
1532
1533    // Extract the alias name part (after "alias/")
1534    let alias_name = alias
1535        .strip_prefix("alias/")
1536        .ok_or_else(|| "KMS alias must start with 'alias/'".to_string())?;
1537
1538    // Check for AWS reserved prefixes
1539    if alias_name.starts_with("aws") || alias_name.ends_with("aws") {
1540        return Err("KMS alias cannot begin or end with 'aws' (reserved by AWS)".to_string());
1541    }
1542
1543    // Check valid characters: alphanumeric, hyphens, underscores, forward slashes
1544    if !alias_name
1545        .chars()
1546        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/')
1547    {
1548        return Err("KMS alias can only contain alphanumeric characters, hyphens, underscores, and forward slashes".to_string());
1549    }
1550
1551    // Check that it's not empty after prefix
1552    if alias_name.is_empty() {
1553        return Err("KMS alias name cannot be empty after 'alias/' prefix".to_string());
1554    }
1555
1556    Ok(())
1557}
1558
1559#[cfg(test)]
1560#[allow(clippy::expect_used)]
1561mod tests {
1562    use super::*;
1563
1564    #[test]
1565    fn test_query_params() {
1566        let query = ApplicationQuery::new()
1567            .with_name("test_app")
1568            .with_policy_compliance("PASSED")
1569            .with_page(1)
1570            .with_size(50);
1571
1572        let params = query.to_query_params();
1573        assert!(params.contains(&("name".to_string(), "test_app".to_string())));
1574        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1575        assert!(params.contains(&("page".to_string(), "1".to_string())));
1576        assert!(params.contains(&("size".to_string(), "50".to_string())));
1577    }
1578
1579    #[test]
1580    fn test_application_query_builder() {
1581        let query = ApplicationQuery::new()
1582            .with_name("MyApp")
1583            .with_policy_compliance("DID_NOT_PASS")
1584            .with_modified_after("2023-01-01T00:00:00.000Z")
1585            .with_page(2)
1586            .with_size(25);
1587
1588        assert_eq!(query.name, Some("MyApp".to_string()));
1589        assert_eq!(query.policy_compliance, Some("DID_NOT_PASS".to_string()));
1590        assert_eq!(
1591            query.modified_after,
1592            Some("2023-01-01T00:00:00.000Z".to_string())
1593        );
1594        assert_eq!(query.page, Some(2));
1595        assert_eq!(query.size, Some(25));
1596    }
1597
1598    #[test]
1599    fn test_application_query_normalize_defaults() {
1600        let query = ApplicationQuery::new();
1601        let normalized = query.normalize().expect("should normalize");
1602
1603        // Should set default page size
1604        assert_eq!(normalized.size, Some(50)); // DEFAULT_PAGE_SIZE
1605        assert_eq!(normalized.page, None);
1606    }
1607
1608    #[test]
1609    fn test_application_query_normalize_valid_values() {
1610        let query = ApplicationQuery::new().with_page(10).with_size(100);
1611        let normalized = query.normalize().expect("should normalize");
1612
1613        assert_eq!(normalized.page, Some(10));
1614        assert_eq!(normalized.size, Some(100));
1615    }
1616
1617    #[test]
1618    fn test_application_query_normalize_zero_size() {
1619        let query = ApplicationQuery::new().with_size(0);
1620        let result = query.normalize();
1621
1622        assert!(result.is_err());
1623    }
1624
1625    #[test]
1626    fn test_application_query_normalize_caps_large_size() {
1627        let query = ApplicationQuery::new().with_size(10000);
1628        let normalized = query.normalize().expect("should cap to max");
1629
1630        // Should be capped to MAX_PAGE_SIZE (500)
1631        assert_eq!(normalized.size, Some(500));
1632    }
1633
1634    #[test]
1635    fn test_application_query_normalize_caps_large_page() {
1636        let query = ApplicationQuery::new().with_page(50000);
1637        let normalized = query.normalize().expect("should cap to max");
1638
1639        // Should be capped to MAX_PAGE_NUMBER (10,000)
1640        assert_eq!(normalized.page, Some(10000));
1641    }
1642
1643    #[test]
1644    fn test_query_params_url_encoding_normal() {
1645        let query = ApplicationQuery::new()
1646            .with_name("MyApp")
1647            .with_policy_compliance("PASSED");
1648
1649        let params = query.to_query_params();
1650
1651        // Normal values should remain unchanged
1652        assert!(params.contains(&("name".to_string(), "MyApp".to_string())));
1653        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1654    }
1655
1656    #[test]
1657    fn test_query_params_url_encoding_special_chars() {
1658        let query = ApplicationQuery::new()
1659            .with_name("My App & Co")
1660            .with_policy_compliance("DID_NOT_PASS");
1661
1662        let params = query.to_query_params();
1663
1664        // Spaces and ampersands should be encoded
1665        assert!(params.contains(&("name".to_string(), "My%20App%20%26%20Co".to_string())));
1666    }
1667
1668    #[test]
1669    fn test_query_params_injection_attempt() {
1670        // Attempt to inject additional parameters via ampersand
1671        let query = ApplicationQuery::new().with_name("foo&admin=true");
1672
1673        let params = query.to_query_params();
1674
1675        // The ampersand should be encoded, preventing injection
1676        assert!(params.contains(&("name".to_string(), "foo%26admin%3Dtrue".to_string())));
1677
1678        // Verify there's no "admin" parameter
1679        assert!(!params.iter().any(|(key, _)| key == "admin"));
1680    }
1681
1682    #[test]
1683    fn test_query_params_equals_injection() {
1684        // Attempt to inject key=value pairs
1685        let query = ApplicationQuery::new().with_name("test=malicious");
1686
1687        let params = query.to_query_params();
1688
1689        // The equals sign should be encoded
1690        assert!(params.contains(&("name".to_string(), "test%3Dmalicious".to_string())));
1691    }
1692
1693    #[test]
1694    fn test_query_params_semicolon_injection() {
1695        // Attempt command injection via semicolon
1696        let query = ApplicationQuery::new().with_name("test;rm -rf /");
1697
1698        let params = query.to_query_params();
1699
1700        // The semicolon and spaces should be encoded
1701        assert!(params.contains(&("name".to_string(), "test%3Brm%20-rf%20%2F".to_string())));
1702    }
1703
1704    #[test]
1705    fn test_query_params_multiple_fields_with_encoding() {
1706        let mut query = ApplicationQuery::new()
1707            .with_name("App & Test")
1708            .with_policy_compliance("PASSED")
1709            .with_modified_after("2023-01-01T00:00:00.000Z");
1710        query.business_unit = Some("Test & Development".to_string());
1711
1712        let params = query.to_query_params();
1713
1714        // Check that all fields are present with proper encoding
1715        assert!(params.contains(&("name".to_string(), "App%20%26%20Test".to_string())));
1716        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1717        assert!(params.contains(&(
1718            "modified_after".to_string(),
1719            "2023-01-01T00%3A00%3A00.000Z".to_string()
1720        )));
1721        assert!(params.contains(&(
1722            "business_unit".to_string(),
1723            "Test%20%26%20Development".to_string()
1724        )));
1725    }
1726
1727    #[test]
1728    fn test_create_application_request_with_teams() {
1729        let team_names = vec!["Security Team".to_string(), "Development Team".to_string()];
1730        let teams: Vec<Team> = team_names
1731            .into_iter()
1732            .map(|team_name| Team {
1733                guid: None,
1734                team_id: None,
1735                team_name: Some(team_name),
1736                team_legacy_id: None,
1737            })
1738            .collect();
1739
1740        let request = CreateApplicationRequest {
1741            profile: CreateApplicationProfile {
1742                name: AppName::new("Test Application").expect("valid name"),
1743                business_criticality: BusinessCriticality::Medium,
1744                description: Some(Description::new("Test description").expect("valid description")),
1745                business_unit: None,
1746                business_owners: None,
1747                policies: None,
1748                teams: Some(teams.clone()),
1749                tags: None,
1750                custom_fields: None,
1751                custom_kms_alias: None,
1752                repo_url: None,
1753            },
1754        };
1755
1756        assert_eq!(request.profile.name.as_str(), "Test Application");
1757        assert_eq!(
1758            request.profile.business_criticality,
1759            BusinessCriticality::Medium
1760        );
1761        assert!(request.profile.teams.is_some());
1762
1763        let request_teams = request.profile.teams.expect("teams should be present");
1764        assert_eq!(request_teams.len(), 2);
1765        assert_eq!(
1766            request_teams
1767                .first()
1768                .expect("should have first team")
1769                .team_name,
1770            Some("Security Team".to_string())
1771        );
1772        assert_eq!(
1773            request_teams
1774                .get(1)
1775                .expect("should have second team")
1776                .team_name,
1777            Some("Development Team".to_string())
1778        );
1779    }
1780
1781    #[test]
1782    fn test_create_application_request_with_team_guids() {
1783        let team_guids = vec!["team-guid-1".to_string(), "team-guid-2".to_string()];
1784        let teams: Vec<Team> = team_guids
1785            .into_iter()
1786            .map(|team_guid| Team {
1787                guid: Some(team_guid),
1788                team_id: None,
1789                team_name: None,
1790                team_legacy_id: None,
1791            })
1792            .collect();
1793
1794        let request = CreateApplicationRequest {
1795            profile: CreateApplicationProfile {
1796                name: AppName::new("Test Application").expect("valid name"),
1797                business_criticality: BusinessCriticality::High,
1798                description: Some(Description::new("Test description").expect("valid description")),
1799                business_unit: None,
1800                business_owners: None,
1801                policies: None,
1802                teams: Some(teams.clone()),
1803                tags: None,
1804                custom_fields: None,
1805                custom_kms_alias: None,
1806                repo_url: None,
1807            },
1808        };
1809
1810        assert_eq!(request.profile.name.as_str(), "Test Application");
1811        assert_eq!(
1812            request.profile.business_criticality,
1813            BusinessCriticality::High
1814        );
1815        assert!(request.profile.teams.is_some());
1816
1817        let request_teams = request.profile.teams.expect("teams should be present");
1818        assert_eq!(request_teams.len(), 2);
1819        assert_eq!(
1820            request_teams.first().expect("should have first team").guid,
1821            Some("team-guid-1".to_string())
1822        );
1823        assert_eq!(
1824            request_teams.get(1).expect("should have second team").guid,
1825            Some("team-guid-2".to_string())
1826        );
1827        assert!(
1828            request_teams
1829                .first()
1830                .expect("should have first team")
1831                .team_name
1832                .is_none()
1833        );
1834        assert!(
1835            request_teams
1836                .get(1)
1837                .expect("should have second team")
1838                .team_name
1839                .is_none()
1840        );
1841    }
1842
1843    #[test]
1844    fn test_create_application_profile_cmek_serialization() {
1845        // Test that custom_kms_alias is included when Some
1846        let profile_with_cmek = CreateApplicationProfile {
1847            name: AppName::new("Test Application").expect("valid name"),
1848            business_criticality: BusinessCriticality::High,
1849            description: None,
1850            business_unit: None,
1851            business_owners: None,
1852            policies: None,
1853            teams: None,
1854            tags: None,
1855            custom_fields: None,
1856            custom_kms_alias: Some("alias/my-app-key".to_string()),
1857            repo_url: None,
1858        };
1859
1860        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1861        assert!(json.contains("custom_kms_alias"));
1862        assert!(json.contains("alias/my-app-key"));
1863
1864        // Test that custom_kms_alias is excluded when None
1865        let profile_without_cmek = CreateApplicationProfile {
1866            name: AppName::new("Test Application").expect("valid name"),
1867            business_criticality: BusinessCriticality::High,
1868            description: None,
1869            business_unit: None,
1870            business_owners: None,
1871            policies: None,
1872            teams: None,
1873            tags: None,
1874            custom_fields: None,
1875            custom_kms_alias: None,
1876            repo_url: None,
1877        };
1878
1879        let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1880        assert!(!json.contains("custom_kms_alias"));
1881    }
1882
1883    #[test]
1884    fn test_update_application_profile_cmek_serialization() {
1885        // Test that custom_kms_alias is included when Some
1886        let profile_with_cmek = UpdateApplicationProfile {
1887            name: Some(AppName::new("Updated Application").expect("valid name")),
1888            description: None,
1889            business_unit: None,
1890            business_owners: None,
1891            business_criticality: BusinessCriticality::Medium,
1892            policies: None,
1893            teams: None,
1894            tags: None,
1895            custom_fields: None,
1896            custom_kms_alias: Some("alias/updated-key".to_string()),
1897            repo_url: None,
1898        };
1899
1900        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1901        assert!(json.contains("custom_kms_alias"));
1902        assert!(json.contains("alias/updated-key"));
1903
1904        // Test that custom_kms_alias is excluded when None
1905        let profile_without_cmek = UpdateApplicationProfile {
1906            name: Some(AppName::new("Updated Application").expect("valid name")),
1907            description: None,
1908            business_unit: None,
1909            business_owners: None,
1910            business_criticality: BusinessCriticality::Medium,
1911            policies: None,
1912            teams: None,
1913            tags: None,
1914            custom_fields: None,
1915            custom_kms_alias: None,
1916            repo_url: None,
1917        };
1918
1919        let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1920        assert!(!json.contains("custom_kms_alias"));
1921    }
1922
1923    #[test]
1924    fn test_validate_kms_alias_valid_cases() {
1925        // Valid aliases
1926        assert!(validate_kms_alias("alias/my-app-key").is_ok());
1927        assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1928        assert!(validate_kms_alias("alias/app/environment/key").is_ok());
1929        assert!(validate_kms_alias("alias/123-test-key").is_ok());
1930    }
1931
1932    #[test]
1933    fn test_validate_kms_alias_invalid_cases() {
1934        // Missing prefix
1935        assert!(validate_kms_alias("my-app-key").is_err());
1936        assert!(validate_kms_alias("invalid-alias").is_err());
1937
1938        // Wrong prefix
1939        assert!(validate_kms_alias("arn:aws:kms:us-east-1:123456789:alias/my-key").is_err());
1940
1941        // AWS reserved names
1942        assert!(validate_kms_alias("alias/aws-managed").is_err());
1943        assert!(validate_kms_alias("alias/my-key-aws").is_err());
1944
1945        // Empty alias name
1946        assert!(validate_kms_alias("alias/").is_err());
1947
1948        // Too short
1949        assert!(validate_kms_alias("alias/a").is_err());
1950
1951        // Invalid characters
1952        assert!(validate_kms_alias("alias/my@key").is_err());
1953        assert!(validate_kms_alias("alias/my key").is_err());
1954        assert!(validate_kms_alias("alias/my.key").is_err());
1955
1956        // Too long (over 256 characters)
1957        let long_alias = format!("alias/{}", "a".repeat(251));
1958        assert!(validate_kms_alias(&long_alias).is_err());
1959    }
1960
1961    #[test]
1962    fn test_cmek_backward_compatibility() {
1963        // Test that existing application creation still works without CMEK field
1964        let legacy_profile = CreateApplicationProfile {
1965            name: AppName::new("Legacy Application").expect("valid name"),
1966            business_criticality: BusinessCriticality::High,
1967            description: Some(
1968                Description::new("Legacy app without CMEK").expect("valid description"),
1969            ),
1970            business_unit: None,
1971            business_owners: None,
1972            policies: None,
1973            teams: None,
1974            tags: None,
1975            custom_fields: None,
1976            custom_kms_alias: None,
1977            repo_url: None,
1978        };
1979
1980        let request = CreateApplicationRequest {
1981            profile: legacy_profile,
1982        };
1983
1984        // Should serialize successfully
1985        let json = serde_json::to_string(&request).expect("should serialize to json");
1986
1987        // Should not contain CMEK field
1988        assert!(!json.contains("custom_kms_alias"));
1989
1990        // Should still contain required fields
1991        assert!(json.contains("name"));
1992        assert!(json.contains("business_criticality"));
1993        assert!(json.contains("Legacy Application"));
1994
1995        // Should be able to deserialize back
1996        let _deserialized: CreateApplicationRequest =
1997            serde_json::from_str(&json).expect("should deserialize json");
1998    }
1999
2000    #[test]
2001    fn test_cmek_field_deserialization() {
2002        // Test deserializing JSON with CMEK field
2003        let json_with_cmek = r#"{
2004            "profile": {
2005                "name": "Test App",
2006                "business_criticality": "HIGH",
2007                "custom_kms_alias": "alias/test-key"
2008            }
2009        }"#;
2010
2011        let request: CreateApplicationRequest =
2012            serde_json::from_str(json_with_cmek).expect("should deserialize json");
2013        assert_eq!(
2014            request.profile.custom_kms_alias,
2015            Some("alias/test-key".to_string())
2016        );
2017
2018        // Test deserializing JSON without CMEK field (backward compatibility)
2019        let json_without_cmek = r#"{
2020            "profile": {
2021                "name": "Test App",
2022                "business_criticality": "HIGH"
2023            }
2024        }"#;
2025
2026        let request: CreateApplicationRequest =
2027            serde_json::from_str(json_without_cmek).expect("should deserialize json");
2028        assert_eq!(request.profile.custom_kms_alias, None);
2029    }
2030
2031    #[test]
2032    fn test_create_application_profile_with_cmek() {
2033        // Test that CreateApplicationProfile includes custom_kms_alias when Some
2034        let profile_with_cmek = CreateApplicationProfile {
2035            name: AppName::new("MyApplication").expect("valid name"),
2036            business_criticality: BusinessCriticality::High,
2037            description: Some(
2038                Description::new("Application created for assessment scanning")
2039                    .expect("valid description"),
2040            ),
2041            business_unit: None,
2042            business_owners: None,
2043            policies: None,
2044            teams: None,
2045            tags: None,
2046            custom_fields: None,
2047            custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2048            repo_url: Some("https://github.com/user/repo".to_string()),
2049        };
2050
2051        let request = CreateApplicationRequest {
2052            profile: profile_with_cmek,
2053        };
2054
2055        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2056
2057        // Print the actual JSON payload that would be sent to Veracode
2058        println!("\n📦 Example JSON Payload sent to Veracode API:");
2059        println!("{}", json);
2060        println!();
2061
2062        assert!(json.contains("custom_kms_alias"));
2063        assert!(json.contains("alias/my-encryption-key"));
2064
2065        // Verify it deserializes correctly
2066        let deserialized: CreateApplicationRequest =
2067            serde_json::from_str(&json).expect("should deserialize json");
2068        assert_eq!(
2069            deserialized.profile.custom_kms_alias,
2070            Some("alias/my-encryption-key".to_string())
2071        );
2072    }
2073
2074    #[test]
2075    fn test_create_application_profile_without_cmek() {
2076        // Test that CreateApplicationProfile excludes custom_kms_alias when None
2077        let profile_without_cmek = CreateApplicationProfile {
2078            name: AppName::new("MyApplication").expect("valid name"),
2079            business_criticality: BusinessCriticality::High,
2080            description: Some(
2081                Description::new("Application created for assessment scanning")
2082                    .expect("valid description"),
2083            ),
2084            business_unit: None,
2085            business_owners: None,
2086            policies: None,
2087            teams: None,
2088            tags: None,
2089            custom_fields: None,
2090            custom_kms_alias: None,
2091            repo_url: Some("https://github.com/user/repo".to_string()),
2092        };
2093
2094        let request = CreateApplicationRequest {
2095            profile: profile_without_cmek,
2096        };
2097
2098        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2099
2100        // Print the actual JSON payload WITHOUT CMEK
2101        println!("\n📦 Example JSON Payload sent to Veracode API (without --cmek):");
2102        println!("{}", json);
2103        println!("⚠️  Notice: 'custom_kms_alias' field is NOT included in the payload");
2104        println!();
2105
2106        assert!(!json.contains("custom_kms_alias"));
2107
2108        // Verify it deserializes correctly
2109        let deserialized: CreateApplicationRequest =
2110            serde_json::from_str(&json).expect("should deserialize json");
2111        assert_eq!(deserialized.profile.custom_kms_alias, None);
2112    }
2113
2114    #[test]
2115    fn test_update_application_profile_with_cmek() {
2116        // Test that UpdateApplicationProfile handles custom_kms_alias correctly
2117        let profile_with_cmek = UpdateApplicationProfile {
2118            name: Some(AppName::new("Updated Application").expect("valid name")),
2119            description: Some(Description::new("Updated description").expect("valid description")),
2120            business_unit: None,
2121            business_owners: None,
2122            business_criticality: BusinessCriticality::Medium,
2123            policies: None,
2124            teams: None,
2125            tags: None,
2126            custom_fields: None,
2127            custom_kms_alias: Some("alias/updated-key".to_string()),
2128            repo_url: None,
2129        };
2130
2131        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
2132        assert!(json.contains("custom_kms_alias"));
2133        assert!(json.contains("alias/updated-key"));
2134    }
2135
2136    /// Test case demonstrating exact JSON payload structure WITH CMEK
2137    /// This documents the API contract when creating applications with encryption enabled
2138    #[test]
2139    fn test_cmek_enabled_payload_structure() {
2140        let request = CreateApplicationRequest {
2141            profile: CreateApplicationProfile {
2142                name: AppName::new("MyApplication").expect("valid name"),
2143                business_criticality: BusinessCriticality::High,
2144                description: Some(
2145                    Description::new("Application created for assessment scanning")
2146                        .expect("valid description"),
2147                ),
2148                business_unit: None,
2149                business_owners: None,
2150                policies: None,
2151                teams: None,
2152                tags: None,
2153                custom_fields: None,
2154                custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2155                repo_url: Some("https://github.com/user/repo".to_string()),
2156            },
2157        };
2158
2159        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2160
2161        // Verify the exact structure matches expected API format
2162        let expected_keys = vec![
2163            "profile",
2164            "name",
2165            "business_criticality",
2166            "description",
2167            "custom_kms_alias",
2168            "repo_url",
2169        ];
2170
2171        for key in expected_keys {
2172            assert!(
2173                json.contains(&format!("\"{key}\"")),
2174                "Expected key '{}' not found in payload",
2175                key
2176            );
2177        }
2178
2179        // Verify custom_kms_alias is present and has correct value
2180        assert!(json.contains("\"custom_kms_alias\": \"alias/my-encryption-key\""));
2181        assert!(json.contains("\"business_criticality\": \"HIGH\""));
2182        assert!(json.contains("\"name\": \"MyApplication\""));
2183
2184        // Parse and verify structure
2185        let parsed: serde_json::Value =
2186            serde_json::from_str(&json).expect("should deserialize json");
2187        assert_eq!(
2188            parsed
2189                .get("profile")
2190                .and_then(|p| p.get("custom_kms_alias"))
2191                .and_then(|v| v.as_str())
2192                .expect("should have custom_kms_alias"),
2193            "alias/my-encryption-key"
2194        );
2195        assert_eq!(
2196            parsed
2197                .get("profile")
2198                .and_then(|p| p.get("business_criticality"))
2199                .and_then(|v| v.as_str())
2200                .expect("should have business_criticality"),
2201            "HIGH"
2202        );
2203    }
2204
2205    /// Test case demonstrating exact JSON payload structure WITHOUT CMEK
2206    /// This documents the API contract when creating applications without encryption
2207    #[test]
2208    fn test_cmek_disabled_payload_structure() {
2209        let request = CreateApplicationRequest {
2210            profile: CreateApplicationProfile {
2211                name: AppName::new("MyApplication").expect("valid name"),
2212                business_criticality: BusinessCriticality::High,
2213                description: Some(
2214                    Description::new("Application created for assessment scanning")
2215                        .expect("valid description"),
2216                ),
2217                business_unit: None,
2218                business_owners: None,
2219                policies: None,
2220                teams: None,
2221                tags: None,
2222                custom_fields: None,
2223                custom_kms_alias: None, // CMEK not specified
2224                repo_url: Some("https://github.com/user/repo".to_string()),
2225            },
2226        };
2227
2228        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2229
2230        // Verify custom_kms_alias is NOT present in the payload
2231        assert!(
2232            !json.contains("custom_kms_alias"),
2233            "custom_kms_alias should not be present when None"
2234        );
2235
2236        // Verify other expected fields are present
2237        assert!(json.contains("\"name\": \"MyApplication\""));
2238        assert!(json.contains("\"business_criticality\": \"HIGH\""));
2239        assert!(json.contains("\"repo_url\""));
2240
2241        // Parse and verify structure
2242        let parsed: serde_json::Value =
2243            serde_json::from_str(&json).expect("should deserialize json");
2244        assert_eq!(
2245            parsed
2246                .get("profile")
2247                .and_then(|p| p.get("name"))
2248                .and_then(|v| v.as_str())
2249                .expect("should have name"),
2250            "MyApplication"
2251        );
2252        assert_eq!(
2253            parsed
2254                .get("profile")
2255                .and_then(|p| p.get("business_criticality"))
2256                .and_then(|v| v.as_str())
2257                .expect("should have business_criticality"),
2258            "HIGH"
2259        );
2260
2261        // Verify custom_kms_alias key doesn't exist in JSON
2262        assert!(
2263            !parsed
2264                .get("profile")
2265                .and_then(|p| p.as_object())
2266                .expect("should have profile object")
2267                .contains_key("custom_kms_alias"),
2268            "custom_kms_alias key should not exist in JSON object"
2269        );
2270    }
2271
2272    /// Test case for various valid CMEK alias formats
2273    #[test]
2274    fn test_cmek_alias_format_variations() {
2275        // Test different valid alias formats
2276        let test_cases = vec![
2277            "alias/production-key",
2278            "alias/dev_environment_key",
2279            "alias/app/prod/2024",
2280            "alias/KEY123",
2281            "alias/my-app-key-2024",
2282        ];
2283
2284        for alias in test_cases {
2285            let request = CreateApplicationRequest {
2286                profile: CreateApplicationProfile {
2287                    name: AppName::new("TestApp").expect("valid name"),
2288                    business_criticality: BusinessCriticality::Medium,
2289                    description: None,
2290                    business_unit: None,
2291                    business_owners: None,
2292                    policies: None,
2293                    teams: None,
2294                    tags: None,
2295                    custom_fields: None,
2296                    custom_kms_alias: Some(alias.to_string()),
2297                    repo_url: None,
2298                },
2299            };
2300
2301            let json = serde_json::to_string(&request).expect("should serialize to json");
2302            assert!(
2303                json.contains(alias),
2304                "Alias '{}' should be present in payload",
2305                alias
2306            );
2307
2308            // Verify it can be deserialized
2309            let parsed: CreateApplicationRequest =
2310                serde_json::from_str(&json).expect("should deserialize json");
2311            assert_eq!(parsed.profile.custom_kms_alias, Some(alias.to_string()));
2312        }
2313    }
2314
2315    /// Test case demonstrating full application profile with all optional fields
2316    #[test]
2317    fn test_complete_application_profile_with_cmek() {
2318        let request = CreateApplicationRequest {
2319            profile: CreateApplicationProfile {
2320                name: AppName::new("CompleteApplication").expect("valid name"),
2321                business_criticality: BusinessCriticality::VeryHigh,
2322                description: Some(
2323                    Description::new("Full featured application with CMEK")
2324                        .expect("valid description"),
2325                ),
2326                business_unit: Some(BusinessUnit {
2327                    id: Some(123),
2328                    name: Some("Engineering".to_string()),
2329                    guid: Some("bu-guid-123".to_string()),
2330                }),
2331                business_owners: Some(vec![BusinessOwner {
2332                    email: Some("owner@example.com".to_string()),
2333                    name: Some("App Owner".to_string()),
2334                }]),
2335                policies: None,
2336                teams: Some(vec![Team {
2337                    guid: Some("team-guid-456".to_string()),
2338                    team_id: None,
2339                    team_name: None,
2340                    team_legacy_id: None,
2341                }]),
2342                tags: Some("production,encrypted".to_string()),
2343                custom_fields: Some(vec![CustomField {
2344                    name: Some("Environment".to_string()),
2345                    value: Some("Production".to_string()),
2346                }]),
2347                custom_kms_alias: Some("alias/production-cmek-key".to_string()),
2348                repo_url: Some("https://github.com/company/secure-app".to_string()),
2349            },
2350        };
2351
2352        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2353
2354        // Verify all major sections are present
2355        assert!(json.contains("\"custom_kms_alias\": \"alias/production-cmek-key\""));
2356        assert!(json.contains("\"business_unit\""));
2357        assert!(json.contains("\"business_owners\""));
2358        assert!(json.contains("\"teams\""));
2359        assert!(json.contains("\"tags\""));
2360        assert!(json.contains("\"custom_fields\""));
2361
2362        // Verify deserialization works with full structure
2363        let parsed: CreateApplicationRequest =
2364            serde_json::from_str(&json).expect("should deserialize json");
2365        assert_eq!(
2366            parsed.profile.custom_kms_alias,
2367            Some("alias/production-cmek-key".to_string())
2368        );
2369        assert!(parsed.profile.business_unit.is_some());
2370        assert!(parsed.profile.business_owners.is_some());
2371    }
2372}
2373
2374#[cfg(test)]
2375#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2376mod proptests {
2377    use super::*;
2378    use proptest::prelude::*;
2379
2380    // Strategy for valid KMS aliases
2381    fn valid_kms_alias_strategy() -> impl Strategy<Value = String> {
2382        // Valid characters: alphanumeric, hyphen, underscore, forward slash
2383        // Length: 8-256 chars including "alias/" prefix
2384        // Must not start/end with "aws"
2385        prop::string::string_regex("[a-zA-Z0-9_/-]{2,250}")
2386            .expect("valid regex pattern for KMS alias")
2387            .prop_map(|s| format!("alias/{}", s))
2388            .prop_filter("Cannot start with aws", |s| {
2389                !s.strip_prefix("alias/").unwrap_or("").starts_with("aws")
2390            })
2391            .prop_filter("Cannot end with aws", |s| {
2392                !s.strip_prefix("alias/").unwrap_or("").ends_with("aws")
2393            })
2394    }
2395
2396    fn invalid_kms_alias_strategy() -> impl Strategy<Value = String> {
2397        prop_oneof![
2398            // Missing prefix
2399            prop::string::string_regex("[a-zA-Z0-9_/-]{5,20}")
2400                .expect("valid regex for missing prefix test"),
2401            // Wrong prefix
2402            Just("arn:aws:kms:us-east-1:123456789:alias/test".to_string()),
2403            // AWS reserved
2404            Just("alias/aws-managed".to_string()),
2405            Just("alias/test-aws".to_string()),
2406            // Empty after prefix
2407            Just("alias/".to_string()),
2408            // Too short
2409            Just("alias/a".to_string()),
2410            // Invalid characters
2411            Just("alias/test@key".to_string()),
2412            Just("alias/test key".to_string()),
2413            Just("alias/test.key".to_string()),
2414            // Too long
2415            prop::string::string_regex("[a-z]{252}")
2416                .expect("valid regex for too long test")
2417                .prop_map(|s| format!("alias/{}", s)),
2418        ]
2419    }
2420
2421    proptest! {
2422        #![proptest_config(ProptestConfig {
2423            cases: if cfg!(miri) { 5 } else { 1000 },
2424            failure_persistence: None, // Required for Miri compatibility
2425            .. ProptestConfig::default()
2426        })]
2427
2428        #[test]
2429        fn proptest_valid_kms_aliases_accepted(alias in valid_kms_alias_strategy()) {
2430            prop_assert!(validate_kms_alias(&alias).is_ok(),
2431                "Valid alias rejected: {}", alias);
2432        }
2433
2434        #[test]
2435        fn proptest_invalid_kms_aliases_rejected(alias in invalid_kms_alias_strategy()) {
2436            prop_assert!(validate_kms_alias(&alias).is_err(),
2437                "Invalid alias accepted: {}", alias);
2438        }
2439
2440        #[test]
2441        fn proptest_kms_alias_length_bounds(
2442            prefix in prop::string::string_regex("[a-zA-Z0-9_/-]{1,7}").expect("valid regex for prefix"),
2443            suffix in prop::string::string_regex("[a-zA-Z0-9_/-]{251,300}").expect("valid regex for suffix")
2444        ) {
2445            let too_short = format!("alias/{}", prefix);
2446            let too_long = format!("alias/{}", suffix);
2447
2448            prop_assert!(validate_kms_alias(&too_short).is_err() || too_short.len() >= 8,
2449                "Too short alias not rejected");
2450            prop_assert!(validate_kms_alias(&too_long).is_err(),
2451                "Too long alias not rejected");
2452        }
2453    }
2454}
2455
2456#[cfg(test)]
2457#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2458mod query_proptests {
2459    use super::*;
2460    use crate::validation::encode_query_param;
2461    use proptest::prelude::*;
2462
2463    proptest! {
2464        #![proptest_config(ProptestConfig {
2465            cases: if cfg!(miri) { 5 } else { 1000 },
2466            failure_persistence: None, // Required for Miri compatibility
2467            .. ProptestConfig::default()
2468        })]
2469
2470        #[test]
2471        fn proptest_query_param_no_injection(
2472            value in prop::string::string_regex(".{1,100}").expect("valid regex for query param")
2473        ) {
2474            let encoded = encode_query_param(&value);
2475
2476            // Encoded value should not contain raw injection characters
2477            prop_assert!(!encoded.contains('&'), "Ampersand not encoded");
2478            prop_assert!(!encoded.contains('=') || value.contains('=') && encoded.contains("%3D"),
2479                "Equals not encoded");
2480            prop_assert!(!encoded.contains(';'), "Semicolon not encoded");
2481        }
2482
2483        #[test]
2484        fn proptest_query_param_path_traversal_encoded(
2485            segments in prop::collection::vec(
2486                prop::string::string_regex("[a-zA-Z0-9]{1,10}").expect("valid regex for path segments"),
2487                1..5
2488            )
2489        ) {
2490            let path_traversal = segments.join("../");
2491            let encoded = encode_query_param(&path_traversal);
2492
2493            // Path traversal is prevented by encoding the separators, not the dots
2494            // The sequence "../" becomes "..%2F" which won't be interpreted as traversal
2495            prop_assert!(!encoded.contains("../"), "Path traversal sequence '../' not broken by encoding");
2496            prop_assert!(!encoded.contains("..\\"), "Path traversal sequence '..\\' not broken by encoding");
2497            prop_assert!(encoded.contains("%2F") || !path_traversal.contains('/'),
2498                "Forward slash not encoded");
2499        }
2500
2501        #[test]
2502        fn proptest_application_query_to_params_no_key_pollution(
2503            name in prop::option::of(prop::string::string_regex("[a-zA-Z0-9 &=;]{1,50}").expect("valid regex for app name")),
2504            compliance in prop::option::of(Just("PASSED".to_string())),
2505            page in prop::option::of(0u32..1000u32),
2506            size in prop::option::of(1u32..1000u32)
2507        ) {
2508            let mut query = ApplicationQuery::new();
2509            if let Some(n) = name {
2510                query = query.with_name(&n);
2511            }
2512            if let Some(c) = compliance {
2513                query = query.with_policy_compliance(&c);
2514            }
2515            query.page = page;
2516            query.size = size;
2517
2518            let params = query.to_query_params();
2519
2520            // Verify each key appears at most once
2521            let mut seen_keys = std::collections::HashSet::new();
2522            for (key, _) in params.iter() {
2523                prop_assert!(!seen_keys.contains(key),
2524                    "Duplicate parameter key: {}", key);
2525                seen_keys.insert(key.clone());
2526            }
2527        }
2528    }
2529}
2530
2531#[cfg(test)]
2532#[allow(clippy::expect_used)] // Test-only validation patterns are safe
2533mod pagination_proptests {
2534    use super::*;
2535    use crate::validation::{MAX_PAGE_NUMBER, MAX_PAGE_SIZE};
2536    use proptest::prelude::*;
2537
2538    proptest! {
2539        #![proptest_config(ProptestConfig {
2540            cases: if cfg!(miri) { 5 } else { 1000 },
2541            failure_persistence: None, // Required for Miri compatibility
2542            .. ProptestConfig::default()
2543        })]
2544
2545        #[test]
2546        fn proptest_page_size_bounds_enforced(size in 0u32..u32::MAX) {
2547            match validate_page_size(Some(size)) {
2548                Ok(validated) => {
2549                    prop_assert!(validated >= 1, "Zero page size accepted");
2550                    prop_assert!(validated <= MAX_PAGE_SIZE,
2551                        "Page size {} exceeds maximum {}", validated, MAX_PAGE_SIZE);
2552                }
2553                Err(_) => {
2554                    prop_assert_eq!(size, 0, "Non-zero size rejected");
2555                }
2556            }
2557        }
2558
2559        #[test]
2560        fn proptest_page_number_bounds_enforced(page in 0u32..u32::MAX) {
2561            let validated = validate_page_number(Some(page)).expect("page number validation should not fail");
2562
2563            if let Some(p) = validated {
2564                prop_assert!(p <= MAX_PAGE_NUMBER,
2565                    "Page number {} exceeds maximum {}", p, MAX_PAGE_NUMBER);
2566            }
2567        }
2568
2569        #[test]
2570        fn proptest_application_query_normalize_safety(
2571            page in prop::option::of(0u32..u32::MAX),
2572            size in prop::option::of(0u32..u32::MAX)
2573        ) {
2574            let mut query = ApplicationQuery::new();
2575            query.page = page;
2576            query.size = size;
2577
2578            match query.normalize() {
2579                Ok(normalized) => {
2580                    // If normalization succeeds, bounds must be enforced
2581                    if let Some(s) = normalized.size {
2582                        prop_assert!((1..=MAX_PAGE_SIZE).contains(&s),
2583                            "Normalized size {} out of bounds", s);
2584                    }
2585                    if let Some(p) = normalized.page {
2586                        prop_assert!(p <= MAX_PAGE_NUMBER,
2587                            "Normalized page {} exceeds maximum", p);
2588                    }
2589                }
2590                Err(_) => {
2591                    // Errors only for zero size
2592                    prop_assert_eq!(size, Some(0), "Unexpected normalization error");
2593                }
2594            }
2595        }
2596    }
2597}
2598#[cfg(test)]
2599mod miri_tests {
2600    use super::*;
2601
2602    #[test]
2603    fn miri_business_owner_debug_redaction() {
2604        let owner = BusinessOwner {
2605            email: Some("sensitive@example.com".to_string()),
2606            name: Some("Sensitive Name".to_string()),
2607        };
2608
2609        let debug_str = format!("{:?}", owner);
2610
2611        // Verify redaction occurred
2612        assert!(debug_str.contains("[REDACTED]"));
2613        assert!(!debug_str.contains("sensitive@example.com"));
2614        assert!(!debug_str.contains("Sensitive Name"));
2615    }
2616
2617    #[test]
2618    fn miri_business_owner_none_fields() {
2619        let owner = BusinessOwner {
2620            email: None,
2621            name: None,
2622        };
2623
2624        // Should not panic or exhibit UB with None fields
2625        let debug_str = format!("{:?}", owner);
2626        assert!(debug_str.contains("[REDACTED]"));
2627    }
2628
2629    #[test]
2630    fn miri_custom_field_debug_redaction() {
2631        let field = CustomField {
2632            name: Some("API_KEY".to_string()),
2633            value: Some("super-secret-key".to_string()),
2634        };
2635
2636        let debug_str = format!("{:?}", field);
2637
2638        // Verify value is redacted but name is visible
2639        assert!(debug_str.contains("API_KEY"));
2640        assert!(debug_str.contains("[REDACTED]"));
2641        assert!(!debug_str.contains("super-secret-key"));
2642    }
2643
2644    #[test]
2645    fn miri_custom_field_none_value() {
2646        let field = CustomField {
2647            name: Some("EMPTY_FIELD".to_string()),
2648            value: None,
2649        };
2650
2651        // Should not panic with None value
2652        let debug_str = format!("{:?}", field);
2653        assert!(debug_str.contains("EMPTY_FIELD"));
2654        assert!(debug_str.contains("[REDACTED]"));
2655    }
2656}
2657
2658#[cfg(test)]
2659mod miri_validation_tests {
2660    use super::*;
2661
2662    #[test]
2663    fn miri_app_name_utf8_boundaries() {
2664        // Test with various UTF-8 characters
2665        let emoji_name = "MyApp 🚀 Test";
2666        let result = AppName::new(emoji_name);
2667        assert!(result.is_ok());
2668
2669        // Test with combining characters
2670        let combining = "Café"; // é is a combining character
2671        let result = AppName::new(combining);
2672        assert!(result.is_ok());
2673    }
2674
2675    #[test]
2676    fn miri_description_null_byte_handling() {
2677        // Ensure null byte check doesn't cause UB
2678        let with_null = "test\0value";
2679        let result = Description::new(with_null);
2680        assert!(result.is_err());
2681
2682        // Verify error type
2683        if let Err(err) = result {
2684            assert!(matches!(err, ValidationError::NullByteInDescription));
2685        }
2686    }
2687
2688    #[test]
2689    fn miri_kms_alias_character_iteration() {
2690        // Test character iteration doesn't violate memory safety
2691        let test_cases = vec![
2692            "alias/test-key",
2693            "alias/test_key_2024",
2694            "alias/app/prod/key",
2695            "alias/UPPERCASE_KEY",
2696        ];
2697
2698        for alias in test_cases {
2699            let _ = validate_kms_alias(alias);
2700        }
2701    }
2702}
2703
2704#[cfg(test)]
2705#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2706mod miri_proptest {
2707    use super::*;
2708    use proptest::prelude::*;
2709
2710    proptest! {
2711        #![proptest_config(ProptestConfig {
2712            cases: if cfg!(miri) { 5 } else { 1000 },
2713            failure_persistence: None, // Required for Miri compatibility
2714            .. ProptestConfig::default()
2715        })]
2716
2717        #[test]
2718        fn miri_proptest_app_name_utf8_safety(
2719            s in prop::string::string_regex("[\\p{L}\\p{N} ]{1,50}").expect("valid regex")
2720        ) {
2721            let _ = AppName::new(&s);
2722            // Miri will catch any UTF-8 boundary violations
2723        }
2724    }
2725}