Skip to main content

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            // CMEK update logic commented out - API limitation prevents reading CMEK status
1087            // Uncomment when API supports returning custom_kms_alias in profile responses
1088            // let mut update_custom_kms_alias = false;
1089
1090            if let Some(ref profile) = existing_app.profile {
1091                // Check repo_url: update if we have one AND existing is None/empty
1092                if repo_url.is_some()
1093                    && (profile.repo_url.is_none()
1094                        || profile
1095                            .repo_url
1096                            .as_ref()
1097                            .is_some_and(|u| u.trim().is_empty()))
1098                {
1099                    update_repo_url = true;
1100                    needs_update = true;
1101                }
1102
1103                // Check description: update if we have one AND existing is None/empty
1104                if description.is_some()
1105                    && (profile.description.is_none()
1106                        || profile
1107                            .description
1108                            .as_ref()
1109                            .is_some_and(|d| d.as_str().trim().is_empty()))
1110                {
1111                    update_description = true;
1112                    needs_update = true;
1113                }
1114
1115                // CMEK update logic commented out - API limitation prevents reading CMEK status
1116                // The Veracode API does not return custom_kms_alias in profile responses,
1117                // so we cannot determine if CMEK is already configured or needs updating.
1118                // To restore: uncomment this block and the related sections below
1119                // // Check custom_kms_alias: update if we have one AND existing is None/empty
1120                // if custom_kms_alias.is_some()
1121                //     && (profile.custom_kms_alias.is_none()
1122                //         || profile
1123                //             .custom_kms_alias
1124                //             .as_ref()
1125                //             .is_some_and(|k| k.trim().is_empty()))
1126                // {
1127                //     update_custom_kms_alias = true;
1128                //     needs_update = true;
1129                // }
1130            }
1131
1132            if needs_update {
1133                log::debug!("🔄 Updating fields for existing application '{}'", name);
1134                if update_repo_url {
1135                    log::debug!(
1136                        "   Setting repo_url: {}",
1137                        repo_url.as_deref().unwrap_or("None")
1138                    );
1139                }
1140                if update_description {
1141                    log::debug!(
1142                        "   Setting description: {}",
1143                        description.as_deref().unwrap_or("None")
1144                    );
1145                }
1146                // CMEK logging commented out - restore when API supports CMEK status retrieval
1147                // if update_custom_kms_alias {
1148                //     log::debug!(
1149                //         "   Setting custom_kms_alias: {}",
1150                //         custom_kms_alias.as_deref().unwrap_or("None")
1151                //     );
1152                // }
1153
1154                // Build update request preserving all existing values
1155                let profile = existing_app.profile.as_ref().ok_or_else(|| {
1156                    VeracodeError::InvalidResponse(format!("Application '{}' has no profile", name))
1157                })?;
1158                let update_request = UpdateApplicationRequest {
1159                    profile: UpdateApplicationProfile {
1160                        name: Some(profile.name.clone()),
1161                        description: if update_description {
1162                            description.map(Description::new).transpose()?
1163                        } else {
1164                            profile.description.clone()
1165                        },
1166                        business_unit: profile.business_unit.clone(),
1167                        business_owners: profile.business_owners.clone(),
1168                        business_criticality: profile.business_criticality, // Keep existing
1169                        policies: profile.policies.clone(),
1170                        teams: profile.teams.clone(), // Keep existing
1171                        tags: profile.tags.clone(),
1172                        custom_fields: profile.custom_fields.clone(),
1173                        // CMEK update commented out - API limitation prevents reliable updates
1174                        // To restore: uncomment this block and the related sections above
1175                        // custom_kms_alias: if update_custom_kms_alias {
1176                        //     custom_kms_alias
1177                        // } else {
1178                        //     profile.custom_kms_alias.clone()
1179                        // },
1180                        custom_kms_alias: profile.custom_kms_alias.clone(), // Always preserve existing (or None)
1181                        repo_url: if update_repo_url {
1182                            repo_url
1183                        } else {
1184                            profile.repo_url.clone()
1185                        },
1186                    },
1187                };
1188
1189                let guid = AppGuid::new(&existing_app.guid)?;
1190                return self.update_application(&guid, &update_request).await;
1191            }
1192
1193            return Ok(existing_app);
1194        }
1195
1196        // Application doesn't exist, create it
1197
1198        // Convert team names to Team objects with GUIDs if provided
1199        let teams = if let Some(names) = team_names {
1200            let identity_api = self.identity_api();
1201            let mut resolved_teams = Vec::new();
1202
1203            for team_name in names {
1204                match identity_api.get_team_guid_by_name(&team_name).await {
1205                    Ok(Some(team_guid)) => {
1206                        resolved_teams.push(Team {
1207                            guid: Some(team_guid),
1208                            team_id: None,
1209                            team_name: None, // Not needed when using GUID
1210                            team_legacy_id: None,
1211                        });
1212                    }
1213                    Ok(None) => {
1214                        return Err(VeracodeError::NotFound(format!(
1215                            "Team '{}' not found",
1216                            team_name
1217                        )));
1218                    }
1219                    Err(identity_err) => {
1220                        return Err(VeracodeError::InvalidResponse(format!(
1221                            "Failed to lookup team '{}': {}",
1222                            team_name, identity_err
1223                        )));
1224                    }
1225                }
1226            }
1227
1228            Some(resolved_teams)
1229        } else {
1230            None
1231        };
1232
1233        let create_request = CreateApplicationRequest {
1234            profile: CreateApplicationProfile {
1235                name: AppName::new(name)?,
1236                business_criticality,
1237                description: description.map(Description::new).transpose()?,
1238                business_unit: None,
1239                business_owners: None,
1240                policies: None,
1241                teams,
1242                tags: None,
1243                custom_fields: None,
1244                custom_kms_alias,
1245                repo_url,
1246            },
1247        };
1248
1249        self.create_application(&create_request).await
1250    }
1251
1252    /// Create application if it doesn't exist, or return existing application (with team GUIDs).
1253    ///
1254    /// This method allows specifying teams by their GUID, which is the preferred
1255    /// approach for programmatic application creation.
1256    ///
1257    /// # Arguments
1258    ///
1259    /// * `name` - The name of the application
1260    /// * `business_criticality` - Business criticality level (required for creation)
1261    /// * `description` - Optional description for new applications
1262    /// * `team_guids` - Optional list of team GUIDs to assign to the application
1263    ///
1264    /// # Returns
1265    ///
1266    /// A `Result` containing the application (existing or newly created).
1267    ///
1268    /// # Errors
1269    ///
1270    /// Returns an error if the API request fails, validation fails,
1271    /// or the response cannot be parsed.
1272    pub async fn create_application_if_not_exists_with_team_guids(
1273        &self,
1274        name: &str,
1275        business_criticality: BusinessCriticality,
1276        description: Option<String>,
1277        team_guids: Option<Vec<String>>,
1278    ) -> Result<Application, VeracodeError> {
1279        // First, check if application already exists
1280        if let Some(existing_app) = self.get_application_by_name(name).await? {
1281            return Ok(existing_app);
1282        }
1283
1284        // Application doesn't exist, create it
1285
1286        // Convert team GUIDs to Team objects if provided
1287        let teams = team_guids.map(|guids| {
1288            guids
1289                .into_iter()
1290                .map(|team_guid| Team {
1291                    guid: Some(team_guid),
1292                    team_id: None,        // Will be assigned by Veracode
1293                    team_name: None,      // Not needed when using GUID
1294                    team_legacy_id: None, // Will be assigned by Veracode
1295                })
1296                .collect()
1297        });
1298
1299        let create_request = CreateApplicationRequest {
1300            profile: CreateApplicationProfile {
1301                name: AppName::new(name)?,
1302                business_criticality,
1303                description: description.map(Description::new).transpose()?,
1304                business_unit: None,
1305                business_owners: None,
1306                policies: None,
1307                teams,
1308                tags: None,
1309                custom_fields: None,
1310                custom_kms_alias: None,
1311                repo_url: None,
1312            },
1313        };
1314
1315        self.create_application(&create_request).await
1316    }
1317
1318    /// Create application if it doesn't exist, or return existing application (without teams).
1319    ///
1320    /// This is a convenience method that maintains backward compatibility
1321    /// for callers that don't need to specify teams.
1322    ///
1323    /// # Arguments
1324    ///
1325    /// * `name` - The name of the application
1326    /// * `business_criticality` - Business criticality level (required for creation)
1327    /// * `description` - Optional description for new applications
1328    ///
1329    /// # Returns
1330    ///
1331    /// A `Result` containing the application (existing or newly created).
1332    ///
1333    /// # Errors
1334    ///
1335    /// Returns an error if the API request fails, validation fails,
1336    /// or the response cannot be parsed.
1337    pub async fn create_application_if_not_exists_simple(
1338        &self,
1339        name: &str,
1340        business_criticality: BusinessCriticality,
1341        description: Option<String>,
1342    ) -> Result<Application, VeracodeError> {
1343        self.create_application_if_not_exists(
1344            name,
1345            business_criticality,
1346            description,
1347            None,
1348            None,
1349            None,
1350        )
1351        .await
1352    }
1353
1354    /// Enable Customer Managed Encryption Key (CMEK) on an application
1355    ///
1356    /// This method updates an existing application to use a customer-managed encryption key.
1357    /// The KMS alias must be properly formatted and the key must be accessible to Veracode.
1358    ///
1359    /// # Arguments
1360    ///
1361    /// * `app_guid` - The GUID of the application to enable encryption on
1362    /// * `kms_alias` - The AWS KMS alias to use for encryption (must start with "alias/")
1363    ///
1364    /// # Returns
1365    ///
1366    /// A `Result` containing the updated application or an error.
1367    ///
1368    /// # Errors
1369    ///
1370    /// Returns an error if the KMS alias format is invalid, the API request fails,
1371    /// the application is not found, or the response cannot be parsed.
1372    ///
1373    /// # Examples
1374    ///
1375    /// ```no_run
1376    /// # use veracode_platform::{VeracodeClient, VeracodeConfig, AppGuid};
1377    /// # use std::sync::Arc;
1378    /// # use secrecy::SecretString;
1379    /// # #[tokio::main]
1380    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1381    /// let config = VeracodeConfig::from_arc_credentials(
1382    ///     Arc::new(SecretString::from("api_id")),
1383    ///     Arc::new(SecretString::from("api_key"))
1384    /// );
1385    /// let client = VeracodeClient::new(config)?;
1386    /// let guid = AppGuid::new("550e8400-e29b-41d4-a716-446655440000")?;
1387    ///
1388    /// let app = client.enable_application_encryption(
1389    ///     &guid,
1390    ///     "alias/my-encryption-key"
1391    /// ).await?;
1392    /// # Ok(())
1393    /// # }
1394    /// ```
1395    pub async fn enable_application_encryption(
1396        &self,
1397        app_guid: &AppGuid,
1398        kms_alias: &str,
1399    ) -> Result<Application, VeracodeError> {
1400        // Validate KMS alias format
1401        validate_kms_alias(kms_alias).map_err(VeracodeError::InvalidConfig)?;
1402
1403        // Get current application to preserve existing settings
1404        let current_app = self.get_application(app_guid).await?;
1405
1406        let profile = current_app
1407            .profile
1408            .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1409
1410        // Create update request with CMEK enabled
1411        let update_request = UpdateApplicationRequest {
1412            profile: UpdateApplicationProfile {
1413                name: Some(profile.name),
1414                description: profile.description,
1415                business_unit: profile.business_unit,
1416                business_owners: profile.business_owners,
1417                business_criticality: profile.business_criticality,
1418                policies: profile.policies,
1419                teams: profile.teams,
1420                tags: profile.tags,
1421                custom_fields: profile.custom_fields,
1422                custom_kms_alias: Some(kms_alias.to_string()),
1423                repo_url: profile.repo_url,
1424            },
1425        };
1426
1427        self.update_application(app_guid, &update_request).await
1428    }
1429
1430    /// Change the encryption key for an application with CMEK enabled
1431    ///
1432    /// This method updates the KMS alias used for encrypting an application's data.
1433    /// The application must already have CMEK enabled.
1434    ///
1435    /// # Arguments
1436    ///
1437    /// * `app_guid` - The GUID of the application to update
1438    /// * `new_kms_alias` - The new AWS KMS alias to use for encryption
1439    ///
1440    /// # Returns
1441    ///
1442    /// A `Result` containing the updated application or an error.
1443    ///
1444    /// # Errors
1445    ///
1446    /// Returns an error if the KMS alias format is invalid, the API request fails,
1447    /// the application is not found, or the response cannot be parsed.
1448    pub async fn change_encryption_key(
1449        &self,
1450        app_guid: &AppGuid,
1451        new_kms_alias: &str,
1452    ) -> Result<Application, VeracodeError> {
1453        // Validate new KMS alias format
1454        validate_kms_alias(new_kms_alias).map_err(VeracodeError::InvalidConfig)?;
1455
1456        // Get current application
1457        let current_app = self.get_application(app_guid).await?;
1458
1459        let profile = current_app
1460            .profile
1461            .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1462
1463        // Create update request with new KMS alias
1464        let update_request = UpdateApplicationRequest {
1465            profile: UpdateApplicationProfile {
1466                name: Some(profile.name),
1467                description: profile.description,
1468                business_unit: profile.business_unit,
1469                business_owners: profile.business_owners,
1470                business_criticality: profile.business_criticality,
1471                policies: profile.policies,
1472                teams: profile.teams,
1473                tags: profile.tags,
1474                custom_fields: profile.custom_fields,
1475                custom_kms_alias: Some(new_kms_alias.to_string()),
1476                repo_url: profile.repo_url,
1477            },
1478        };
1479
1480        self.update_application(app_guid, &update_request).await
1481    }
1482
1483    /// Get the encryption status of an application
1484    ///
1485    /// This method retrieves the current CMEK configuration for an application.
1486    ///
1487    /// # Arguments
1488    ///
1489    /// * `app_guid` - The GUID of the application to check
1490    ///
1491    /// # Returns
1492    ///
1493    /// A `Result` containing the KMS alias if CMEK is enabled, None if disabled, or an error.
1494    ///
1495    /// # Errors
1496    ///
1497    /// Returns an error if the API request fails, the application is not found,
1498    /// or the response cannot be parsed.
1499    pub async fn get_application_encryption_status(
1500        &self,
1501        app_guid: &AppGuid,
1502    ) -> Result<Option<String>, VeracodeError> {
1503        let app = self.get_application(app_guid).await?;
1504
1505        // CMEK is stored directly in the profile as custom_kms_alias, not in custom_fields
1506        Ok(app.profile.and_then(|profile| profile.custom_kms_alias))
1507    }
1508}
1509
1510/// Validates an AWS KMS alias format
1511///
1512/// AWS KMS aliases must follow specific naming conventions:
1513/// - Must be prefixed with "alias/"
1514/// - Total length must be between 8-256 characters
1515/// - Can contain alphanumeric characters, hyphens, underscores, and forward slashes
1516/// - Cannot begin or end with "aws" (reserved by AWS)
1517///
1518/// # Examples
1519///
1520/// ```
1521/// use veracode_platform::app::validate_kms_alias;
1522///
1523/// assert!(validate_kms_alias("alias/my-app-key").is_ok());
1524/// assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1525/// assert!(validate_kms_alias("invalid-alias").is_err());
1526/// assert!(validate_kms_alias("alias/aws-managed").is_err());
1527/// ```
1528///
1529/// # Errors
1530///
1531/// Returns an error if the alias doesn't meet AWS KMS naming requirements.
1532pub fn validate_kms_alias(alias: &str) -> Result<(), String> {
1533    // Check prefix
1534    if !alias.starts_with("alias/") {
1535        return Err("KMS alias must start with 'alias/'".to_string());
1536    }
1537
1538    // Check length (including the "alias/" prefix) - minimum 8 characters for meaningful alias
1539    if alias.len() < 8 || alias.len() > 256 {
1540        return Err("KMS alias must be between 8 and 256 characters long".to_string());
1541    }
1542
1543    // Extract the alias name part (after "alias/")
1544    let alias_name = alias
1545        .strip_prefix("alias/")
1546        .ok_or_else(|| "KMS alias must start with 'alias/'".to_string())?;
1547
1548    // Check for AWS reserved prefixes
1549    if alias_name.starts_with("aws") || alias_name.ends_with("aws") {
1550        return Err("KMS alias cannot begin or end with 'aws' (reserved by AWS)".to_string());
1551    }
1552
1553    // Check valid characters: alphanumeric, hyphens, underscores, forward slashes
1554    if !alias_name
1555        .chars()
1556        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/')
1557    {
1558        return Err("KMS alias can only contain alphanumeric characters, hyphens, underscores, and forward slashes".to_string());
1559    }
1560
1561    // Check that it's not empty after prefix
1562    if alias_name.is_empty() {
1563        return Err("KMS alias name cannot be empty after 'alias/' prefix".to_string());
1564    }
1565
1566    Ok(())
1567}
1568
1569#[cfg(test)]
1570#[allow(clippy::expect_used)]
1571mod tests {
1572    use super::*;
1573
1574    #[test]
1575    fn test_query_params() {
1576        let query = ApplicationQuery::new()
1577            .with_name("test_app")
1578            .with_policy_compliance("PASSED")
1579            .with_page(1)
1580            .with_size(50);
1581
1582        let params = query.to_query_params();
1583        assert!(params.contains(&("name".to_string(), "test_app".to_string())));
1584        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1585        assert!(params.contains(&("page".to_string(), "1".to_string())));
1586        assert!(params.contains(&("size".to_string(), "50".to_string())));
1587    }
1588
1589    #[test]
1590    fn test_application_query_builder() {
1591        let query = ApplicationQuery::new()
1592            .with_name("MyApp")
1593            .with_policy_compliance("DID_NOT_PASS")
1594            .with_modified_after("2023-01-01T00:00:00.000Z")
1595            .with_page(2)
1596            .with_size(25);
1597
1598        assert_eq!(query.name, Some("MyApp".to_string()));
1599        assert_eq!(query.policy_compliance, Some("DID_NOT_PASS".to_string()));
1600        assert_eq!(
1601            query.modified_after,
1602            Some("2023-01-01T00:00:00.000Z".to_string())
1603        );
1604        assert_eq!(query.page, Some(2));
1605        assert_eq!(query.size, Some(25));
1606    }
1607
1608    #[test]
1609    fn test_application_query_normalize_defaults() {
1610        let query = ApplicationQuery::new();
1611        let normalized = query.normalize().expect("should normalize");
1612
1613        // Should set default page size
1614        assert_eq!(normalized.size, Some(50)); // DEFAULT_PAGE_SIZE
1615        assert_eq!(normalized.page, None);
1616    }
1617
1618    #[test]
1619    fn test_application_query_normalize_valid_values() {
1620        let query = ApplicationQuery::new().with_page(10).with_size(100);
1621        let normalized = query.normalize().expect("should normalize");
1622
1623        assert_eq!(normalized.page, Some(10));
1624        assert_eq!(normalized.size, Some(100));
1625    }
1626
1627    #[test]
1628    fn test_application_query_normalize_zero_size() {
1629        let query = ApplicationQuery::new().with_size(0);
1630        let result = query.normalize();
1631
1632        assert!(result.is_err());
1633    }
1634
1635    #[test]
1636    fn test_application_query_normalize_caps_large_size() {
1637        let query = ApplicationQuery::new().with_size(10000);
1638        let normalized = query.normalize().expect("should cap to max");
1639
1640        // Should be capped to MAX_PAGE_SIZE (500)
1641        assert_eq!(normalized.size, Some(500));
1642    }
1643
1644    #[test]
1645    fn test_application_query_normalize_caps_large_page() {
1646        let query = ApplicationQuery::new().with_page(50000);
1647        let normalized = query.normalize().expect("should cap to max");
1648
1649        // Should be capped to MAX_PAGE_NUMBER (10,000)
1650        assert_eq!(normalized.page, Some(10000));
1651    }
1652
1653    #[test]
1654    fn test_query_params_url_encoding_normal() {
1655        let query = ApplicationQuery::new()
1656            .with_name("MyApp")
1657            .with_policy_compliance("PASSED");
1658
1659        let params = query.to_query_params();
1660
1661        // Normal values should remain unchanged
1662        assert!(params.contains(&("name".to_string(), "MyApp".to_string())));
1663        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1664    }
1665
1666    #[test]
1667    fn test_query_params_url_encoding_special_chars() {
1668        let query = ApplicationQuery::new()
1669            .with_name("My App & Co")
1670            .with_policy_compliance("DID_NOT_PASS");
1671
1672        let params = query.to_query_params();
1673
1674        // Spaces and ampersands should be encoded
1675        assert!(params.contains(&("name".to_string(), "My%20App%20%26%20Co".to_string())));
1676    }
1677
1678    #[test]
1679    fn test_query_params_injection_attempt() {
1680        // Attempt to inject additional parameters via ampersand
1681        let query = ApplicationQuery::new().with_name("foo&admin=true");
1682
1683        let params = query.to_query_params();
1684
1685        // The ampersand should be encoded, preventing injection
1686        assert!(params.contains(&("name".to_string(), "foo%26admin%3Dtrue".to_string())));
1687
1688        // Verify there's no "admin" parameter
1689        assert!(!params.iter().any(|(key, _)| key == "admin"));
1690    }
1691
1692    #[test]
1693    fn test_query_params_equals_injection() {
1694        // Attempt to inject key=value pairs
1695        let query = ApplicationQuery::new().with_name("test=malicious");
1696
1697        let params = query.to_query_params();
1698
1699        // The equals sign should be encoded
1700        assert!(params.contains(&("name".to_string(), "test%3Dmalicious".to_string())));
1701    }
1702
1703    #[test]
1704    fn test_query_params_semicolon_injection() {
1705        // Attempt command injection via semicolon
1706        let query = ApplicationQuery::new().with_name("test;rm -rf /");
1707
1708        let params = query.to_query_params();
1709
1710        // The semicolon and spaces should be encoded
1711        assert!(params.contains(&("name".to_string(), "test%3Brm%20-rf%20%2F".to_string())));
1712    }
1713
1714    #[test]
1715    fn test_query_params_multiple_fields_with_encoding() {
1716        let mut query = ApplicationQuery::new()
1717            .with_name("App & Test")
1718            .with_policy_compliance("PASSED")
1719            .with_modified_after("2023-01-01T00:00:00.000Z");
1720        query.business_unit = Some("Test & Development".to_string());
1721
1722        let params = query.to_query_params();
1723
1724        // Check that all fields are present with proper encoding
1725        assert!(params.contains(&("name".to_string(), "App%20%26%20Test".to_string())));
1726        assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1727        assert!(params.contains(&(
1728            "modified_after".to_string(),
1729            "2023-01-01T00%3A00%3A00.000Z".to_string()
1730        )));
1731        assert!(params.contains(&(
1732            "business_unit".to_string(),
1733            "Test%20%26%20Development".to_string()
1734        )));
1735    }
1736
1737    #[test]
1738    fn test_create_application_request_with_teams() {
1739        let team_names = vec!["Security Team".to_string(), "Development Team".to_string()];
1740        let teams: Vec<Team> = team_names
1741            .into_iter()
1742            .map(|team_name| Team {
1743                guid: None,
1744                team_id: None,
1745                team_name: Some(team_name),
1746                team_legacy_id: None,
1747            })
1748            .collect();
1749
1750        let request = CreateApplicationRequest {
1751            profile: CreateApplicationProfile {
1752                name: AppName::new("Test Application").expect("valid name"),
1753                business_criticality: BusinessCriticality::Medium,
1754                description: Some(Description::new("Test description").expect("valid description")),
1755                business_unit: None,
1756                business_owners: None,
1757                policies: None,
1758                teams: Some(teams.clone()),
1759                tags: None,
1760                custom_fields: None,
1761                custom_kms_alias: None,
1762                repo_url: None,
1763            },
1764        };
1765
1766        assert_eq!(request.profile.name.as_str(), "Test Application");
1767        assert_eq!(
1768            request.profile.business_criticality,
1769            BusinessCriticality::Medium
1770        );
1771        assert!(request.profile.teams.is_some());
1772
1773        let request_teams = request.profile.teams.expect("teams should be present");
1774        assert_eq!(request_teams.len(), 2);
1775        assert_eq!(
1776            request_teams
1777                .first()
1778                .expect("should have first team")
1779                .team_name,
1780            Some("Security Team".to_string())
1781        );
1782        assert_eq!(
1783            request_teams
1784                .get(1)
1785                .expect("should have second team")
1786                .team_name,
1787            Some("Development Team".to_string())
1788        );
1789    }
1790
1791    #[test]
1792    fn test_create_application_request_with_team_guids() {
1793        let team_guids = vec!["team-guid-1".to_string(), "team-guid-2".to_string()];
1794        let teams: Vec<Team> = team_guids
1795            .into_iter()
1796            .map(|team_guid| Team {
1797                guid: Some(team_guid),
1798                team_id: None,
1799                team_name: None,
1800                team_legacy_id: None,
1801            })
1802            .collect();
1803
1804        let request = CreateApplicationRequest {
1805            profile: CreateApplicationProfile {
1806                name: AppName::new("Test Application").expect("valid name"),
1807                business_criticality: BusinessCriticality::High,
1808                description: Some(Description::new("Test description").expect("valid description")),
1809                business_unit: None,
1810                business_owners: None,
1811                policies: None,
1812                teams: Some(teams.clone()),
1813                tags: None,
1814                custom_fields: None,
1815                custom_kms_alias: None,
1816                repo_url: None,
1817            },
1818        };
1819
1820        assert_eq!(request.profile.name.as_str(), "Test Application");
1821        assert_eq!(
1822            request.profile.business_criticality,
1823            BusinessCriticality::High
1824        );
1825        assert!(request.profile.teams.is_some());
1826
1827        let request_teams = request.profile.teams.expect("teams should be present");
1828        assert_eq!(request_teams.len(), 2);
1829        assert_eq!(
1830            request_teams.first().expect("should have first team").guid,
1831            Some("team-guid-1".to_string())
1832        );
1833        assert_eq!(
1834            request_teams.get(1).expect("should have second team").guid,
1835            Some("team-guid-2".to_string())
1836        );
1837        assert!(
1838            request_teams
1839                .first()
1840                .expect("should have first team")
1841                .team_name
1842                .is_none()
1843        );
1844        assert!(
1845            request_teams
1846                .get(1)
1847                .expect("should have second team")
1848                .team_name
1849                .is_none()
1850        );
1851    }
1852
1853    #[test]
1854    fn test_create_application_profile_cmek_serialization() {
1855        // Test that custom_kms_alias is included when Some
1856        let profile_with_cmek = CreateApplicationProfile {
1857            name: AppName::new("Test Application").expect("valid name"),
1858            business_criticality: BusinessCriticality::High,
1859            description: None,
1860            business_unit: None,
1861            business_owners: None,
1862            policies: None,
1863            teams: None,
1864            tags: None,
1865            custom_fields: None,
1866            custom_kms_alias: Some("alias/my-app-key".to_string()),
1867            repo_url: None,
1868        };
1869
1870        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1871        assert!(json.contains("custom_kms_alias"));
1872        assert!(json.contains("alias/my-app-key"));
1873
1874        // Test that custom_kms_alias is excluded when None
1875        let profile_without_cmek = CreateApplicationProfile {
1876            name: AppName::new("Test Application").expect("valid name"),
1877            business_criticality: BusinessCriticality::High,
1878            description: None,
1879            business_unit: None,
1880            business_owners: None,
1881            policies: None,
1882            teams: None,
1883            tags: None,
1884            custom_fields: None,
1885            custom_kms_alias: None,
1886            repo_url: None,
1887        };
1888
1889        let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1890        assert!(!json.contains("custom_kms_alias"));
1891    }
1892
1893    #[test]
1894    fn test_update_application_profile_cmek_serialization() {
1895        // Test that custom_kms_alias is included when Some
1896        let profile_with_cmek = UpdateApplicationProfile {
1897            name: Some(AppName::new("Updated Application").expect("valid name")),
1898            description: None,
1899            business_unit: None,
1900            business_owners: None,
1901            business_criticality: BusinessCriticality::Medium,
1902            policies: None,
1903            teams: None,
1904            tags: None,
1905            custom_fields: None,
1906            custom_kms_alias: Some("alias/updated-key".to_string()),
1907            repo_url: None,
1908        };
1909
1910        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1911        assert!(json.contains("custom_kms_alias"));
1912        assert!(json.contains("alias/updated-key"));
1913
1914        // Test that custom_kms_alias is excluded when None
1915        let profile_without_cmek = UpdateApplicationProfile {
1916            name: Some(AppName::new("Updated Application").expect("valid name")),
1917            description: None,
1918            business_unit: None,
1919            business_owners: None,
1920            business_criticality: BusinessCriticality::Medium,
1921            policies: None,
1922            teams: None,
1923            tags: None,
1924            custom_fields: None,
1925            custom_kms_alias: None,
1926            repo_url: None,
1927        };
1928
1929        let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1930        assert!(!json.contains("custom_kms_alias"));
1931    }
1932
1933    #[test]
1934    fn test_validate_kms_alias_valid_cases() {
1935        // Valid aliases
1936        assert!(validate_kms_alias("alias/my-app-key").is_ok());
1937        assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1938        assert!(validate_kms_alias("alias/app/environment/key").is_ok());
1939        assert!(validate_kms_alias("alias/123-test-key").is_ok());
1940    }
1941
1942    #[test]
1943    fn test_validate_kms_alias_invalid_cases() {
1944        // Missing prefix
1945        assert!(validate_kms_alias("my-app-key").is_err());
1946        assert!(validate_kms_alias("invalid-alias").is_err());
1947
1948        // Wrong prefix
1949        assert!(validate_kms_alias("arn:aws:kms:us-east-1:123456789:alias/my-key").is_err());
1950
1951        // AWS reserved names
1952        assert!(validate_kms_alias("alias/aws-managed").is_err());
1953        assert!(validate_kms_alias("alias/my-key-aws").is_err());
1954
1955        // Empty alias name
1956        assert!(validate_kms_alias("alias/").is_err());
1957
1958        // Too short
1959        assert!(validate_kms_alias("alias/a").is_err());
1960
1961        // Invalid characters
1962        assert!(validate_kms_alias("alias/my@key").is_err());
1963        assert!(validate_kms_alias("alias/my key").is_err());
1964        assert!(validate_kms_alias("alias/my.key").is_err());
1965
1966        // Too long (over 256 characters)
1967        let long_alias = format!("alias/{}", "a".repeat(251));
1968        assert!(validate_kms_alias(&long_alias).is_err());
1969    }
1970
1971    #[test]
1972    fn test_cmek_backward_compatibility() {
1973        // Test that existing application creation still works without CMEK field
1974        let legacy_profile = CreateApplicationProfile {
1975            name: AppName::new("Legacy Application").expect("valid name"),
1976            business_criticality: BusinessCriticality::High,
1977            description: Some(
1978                Description::new("Legacy app without CMEK").expect("valid description"),
1979            ),
1980            business_unit: None,
1981            business_owners: None,
1982            policies: None,
1983            teams: None,
1984            tags: None,
1985            custom_fields: None,
1986            custom_kms_alias: None,
1987            repo_url: None,
1988        };
1989
1990        let request = CreateApplicationRequest {
1991            profile: legacy_profile,
1992        };
1993
1994        // Should serialize successfully
1995        let json = serde_json::to_string(&request).expect("should serialize to json");
1996
1997        // Should not contain CMEK field
1998        assert!(!json.contains("custom_kms_alias"));
1999
2000        // Should still contain required fields
2001        assert!(json.contains("name"));
2002        assert!(json.contains("business_criticality"));
2003        assert!(json.contains("Legacy Application"));
2004
2005        // Should be able to deserialize back
2006        let _deserialized: CreateApplicationRequest =
2007            serde_json::from_str(&json).expect("should deserialize json");
2008    }
2009
2010    #[test]
2011    fn test_cmek_field_deserialization() {
2012        // Test deserializing JSON with CMEK field
2013        let json_with_cmek = r#"{
2014            "profile": {
2015                "name": "Test App",
2016                "business_criticality": "HIGH",
2017                "custom_kms_alias": "alias/test-key"
2018            }
2019        }"#;
2020
2021        let request: CreateApplicationRequest =
2022            serde_json::from_str(json_with_cmek).expect("should deserialize json");
2023        assert_eq!(
2024            request.profile.custom_kms_alias,
2025            Some("alias/test-key".to_string())
2026        );
2027
2028        // Test deserializing JSON without CMEK field (backward compatibility)
2029        let json_without_cmek = r#"{
2030            "profile": {
2031                "name": "Test App",
2032                "business_criticality": "HIGH"
2033            }
2034        }"#;
2035
2036        let request: CreateApplicationRequest =
2037            serde_json::from_str(json_without_cmek).expect("should deserialize json");
2038        assert_eq!(request.profile.custom_kms_alias, None);
2039    }
2040
2041    #[test]
2042    fn test_create_application_profile_with_cmek() {
2043        // Test that CreateApplicationProfile includes custom_kms_alias when Some
2044        let profile_with_cmek = CreateApplicationProfile {
2045            name: AppName::new("MyApplication").expect("valid name"),
2046            business_criticality: BusinessCriticality::High,
2047            description: Some(
2048                Description::new("Application created for assessment scanning")
2049                    .expect("valid description"),
2050            ),
2051            business_unit: None,
2052            business_owners: None,
2053            policies: None,
2054            teams: None,
2055            tags: None,
2056            custom_fields: None,
2057            custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2058            repo_url: Some("https://github.com/user/repo".to_string()),
2059        };
2060
2061        let request = CreateApplicationRequest {
2062            profile: profile_with_cmek,
2063        };
2064
2065        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2066
2067        // Print the actual JSON payload that would be sent to Veracode
2068        println!("\n📦 Example JSON Payload sent to Veracode API:");
2069        println!("{}", json);
2070        println!();
2071
2072        assert!(json.contains("custom_kms_alias"));
2073        assert!(json.contains("alias/my-encryption-key"));
2074
2075        // Verify it deserializes correctly
2076        let deserialized: CreateApplicationRequest =
2077            serde_json::from_str(&json).expect("should deserialize json");
2078        assert_eq!(
2079            deserialized.profile.custom_kms_alias,
2080            Some("alias/my-encryption-key".to_string())
2081        );
2082    }
2083
2084    #[test]
2085    fn test_create_application_profile_without_cmek() {
2086        // Test that CreateApplicationProfile excludes custom_kms_alias when None
2087        let profile_without_cmek = CreateApplicationProfile {
2088            name: AppName::new("MyApplication").expect("valid name"),
2089            business_criticality: BusinessCriticality::High,
2090            description: Some(
2091                Description::new("Application created for assessment scanning")
2092                    .expect("valid description"),
2093            ),
2094            business_unit: None,
2095            business_owners: None,
2096            policies: None,
2097            teams: None,
2098            tags: None,
2099            custom_fields: None,
2100            custom_kms_alias: None,
2101            repo_url: Some("https://github.com/user/repo".to_string()),
2102        };
2103
2104        let request = CreateApplicationRequest {
2105            profile: profile_without_cmek,
2106        };
2107
2108        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2109
2110        // Print the actual JSON payload WITHOUT CMEK
2111        println!("\n📦 Example JSON Payload sent to Veracode API (without --cmek):");
2112        println!("{}", json);
2113        println!("⚠️  Notice: 'custom_kms_alias' field is NOT included in the payload");
2114        println!();
2115
2116        assert!(!json.contains("custom_kms_alias"));
2117
2118        // Verify it deserializes correctly
2119        let deserialized: CreateApplicationRequest =
2120            serde_json::from_str(&json).expect("should deserialize json");
2121        assert_eq!(deserialized.profile.custom_kms_alias, None);
2122    }
2123
2124    #[test]
2125    fn test_update_application_profile_with_cmek() {
2126        // Test that UpdateApplicationProfile handles custom_kms_alias correctly
2127        let profile_with_cmek = UpdateApplicationProfile {
2128            name: Some(AppName::new("Updated Application").expect("valid name")),
2129            description: Some(Description::new("Updated description").expect("valid description")),
2130            business_unit: None,
2131            business_owners: None,
2132            business_criticality: BusinessCriticality::Medium,
2133            policies: None,
2134            teams: None,
2135            tags: None,
2136            custom_fields: None,
2137            custom_kms_alias: Some("alias/updated-key".to_string()),
2138            repo_url: None,
2139        };
2140
2141        let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
2142        assert!(json.contains("custom_kms_alias"));
2143        assert!(json.contains("alias/updated-key"));
2144    }
2145
2146    /// Test case demonstrating exact JSON payload structure WITH CMEK
2147    /// This documents the API contract when creating applications with encryption enabled
2148    #[test]
2149    fn test_cmek_enabled_payload_structure() {
2150        let request = CreateApplicationRequest {
2151            profile: CreateApplicationProfile {
2152                name: AppName::new("MyApplication").expect("valid name"),
2153                business_criticality: BusinessCriticality::High,
2154                description: Some(
2155                    Description::new("Application created for assessment scanning")
2156                        .expect("valid description"),
2157                ),
2158                business_unit: None,
2159                business_owners: None,
2160                policies: None,
2161                teams: None,
2162                tags: None,
2163                custom_fields: None,
2164                custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2165                repo_url: Some("https://github.com/user/repo".to_string()),
2166            },
2167        };
2168
2169        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2170
2171        // Verify the exact structure matches expected API format
2172        let expected_keys = vec![
2173            "profile",
2174            "name",
2175            "business_criticality",
2176            "description",
2177            "custom_kms_alias",
2178            "repo_url",
2179        ];
2180
2181        for key in expected_keys {
2182            assert!(
2183                json.contains(&format!("\"{key}\"")),
2184                "Expected key '{}' not found in payload",
2185                key
2186            );
2187        }
2188
2189        // Verify custom_kms_alias is present and has correct value
2190        assert!(json.contains("\"custom_kms_alias\": \"alias/my-encryption-key\""));
2191        assert!(json.contains("\"business_criticality\": \"HIGH\""));
2192        assert!(json.contains("\"name\": \"MyApplication\""));
2193
2194        // Parse and verify structure
2195        let parsed: serde_json::Value =
2196            serde_json::from_str(&json).expect("should deserialize json");
2197        assert_eq!(
2198            parsed
2199                .get("profile")
2200                .and_then(|p| p.get("custom_kms_alias"))
2201                .and_then(|v| v.as_str())
2202                .expect("should have custom_kms_alias"),
2203            "alias/my-encryption-key"
2204        );
2205        assert_eq!(
2206            parsed
2207                .get("profile")
2208                .and_then(|p| p.get("business_criticality"))
2209                .and_then(|v| v.as_str())
2210                .expect("should have business_criticality"),
2211            "HIGH"
2212        );
2213    }
2214
2215    /// Test case demonstrating exact JSON payload structure WITHOUT CMEK
2216    /// This documents the API contract when creating applications without encryption
2217    #[test]
2218    fn test_cmek_disabled_payload_structure() {
2219        let request = CreateApplicationRequest {
2220            profile: CreateApplicationProfile {
2221                name: AppName::new("MyApplication").expect("valid name"),
2222                business_criticality: BusinessCriticality::High,
2223                description: Some(
2224                    Description::new("Application created for assessment scanning")
2225                        .expect("valid description"),
2226                ),
2227                business_unit: None,
2228                business_owners: None,
2229                policies: None,
2230                teams: None,
2231                tags: None,
2232                custom_fields: None,
2233                custom_kms_alias: None, // CMEK not specified
2234                repo_url: Some("https://github.com/user/repo".to_string()),
2235            },
2236        };
2237
2238        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2239
2240        // Verify custom_kms_alias is NOT present in the payload
2241        assert!(
2242            !json.contains("custom_kms_alias"),
2243            "custom_kms_alias should not be present when None"
2244        );
2245
2246        // Verify other expected fields are present
2247        assert!(json.contains("\"name\": \"MyApplication\""));
2248        assert!(json.contains("\"business_criticality\": \"HIGH\""));
2249        assert!(json.contains("\"repo_url\""));
2250
2251        // Parse and verify structure
2252        let parsed: serde_json::Value =
2253            serde_json::from_str(&json).expect("should deserialize json");
2254        assert_eq!(
2255            parsed
2256                .get("profile")
2257                .and_then(|p| p.get("name"))
2258                .and_then(|v| v.as_str())
2259                .expect("should have name"),
2260            "MyApplication"
2261        );
2262        assert_eq!(
2263            parsed
2264                .get("profile")
2265                .and_then(|p| p.get("business_criticality"))
2266                .and_then(|v| v.as_str())
2267                .expect("should have business_criticality"),
2268            "HIGH"
2269        );
2270
2271        // Verify custom_kms_alias key doesn't exist in JSON
2272        assert!(
2273            !parsed
2274                .get("profile")
2275                .and_then(|p| p.as_object())
2276                .expect("should have profile object")
2277                .contains_key("custom_kms_alias"),
2278            "custom_kms_alias key should not exist in JSON object"
2279        );
2280    }
2281
2282    /// Test case for various valid CMEK alias formats
2283    #[test]
2284    fn test_cmek_alias_format_variations() {
2285        // Test different valid alias formats
2286        let test_cases = vec![
2287            "alias/production-key",
2288            "alias/dev_environment_key",
2289            "alias/app/prod/2024",
2290            "alias/KEY123",
2291            "alias/my-app-key-2024",
2292        ];
2293
2294        for alias in test_cases {
2295            let request = CreateApplicationRequest {
2296                profile: CreateApplicationProfile {
2297                    name: AppName::new("TestApp").expect("valid name"),
2298                    business_criticality: BusinessCriticality::Medium,
2299                    description: None,
2300                    business_unit: None,
2301                    business_owners: None,
2302                    policies: None,
2303                    teams: None,
2304                    tags: None,
2305                    custom_fields: None,
2306                    custom_kms_alias: Some(alias.to_string()),
2307                    repo_url: None,
2308                },
2309            };
2310
2311            let json = serde_json::to_string(&request).expect("should serialize to json");
2312            assert!(
2313                json.contains(alias),
2314                "Alias '{}' should be present in payload",
2315                alias
2316            );
2317
2318            // Verify it can be deserialized
2319            let parsed: CreateApplicationRequest =
2320                serde_json::from_str(&json).expect("should deserialize json");
2321            assert_eq!(parsed.profile.custom_kms_alias, Some(alias.to_string()));
2322        }
2323    }
2324
2325    /// Test case demonstrating full application profile with all optional fields
2326    #[test]
2327    fn test_complete_application_profile_with_cmek() {
2328        let request = CreateApplicationRequest {
2329            profile: CreateApplicationProfile {
2330                name: AppName::new("CompleteApplication").expect("valid name"),
2331                business_criticality: BusinessCriticality::VeryHigh,
2332                description: Some(
2333                    Description::new("Full featured application with CMEK")
2334                        .expect("valid description"),
2335                ),
2336                business_unit: Some(BusinessUnit {
2337                    id: Some(123),
2338                    name: Some("Engineering".to_string()),
2339                    guid: Some("bu-guid-123".to_string()),
2340                }),
2341                business_owners: Some(vec![BusinessOwner {
2342                    email: Some("owner@example.com".to_string()),
2343                    name: Some("App Owner".to_string()),
2344                }]),
2345                policies: None,
2346                teams: Some(vec![Team {
2347                    guid: Some("team-guid-456".to_string()),
2348                    team_id: None,
2349                    team_name: None,
2350                    team_legacy_id: None,
2351                }]),
2352                tags: Some("production,encrypted".to_string()),
2353                custom_fields: Some(vec![CustomField {
2354                    name: Some("Environment".to_string()),
2355                    value: Some("Production".to_string()),
2356                }]),
2357                custom_kms_alias: Some("alias/production-cmek-key".to_string()),
2358                repo_url: Some("https://github.com/company/secure-app".to_string()),
2359            },
2360        };
2361
2362        let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2363
2364        // Verify all major sections are present
2365        assert!(json.contains("\"custom_kms_alias\": \"alias/production-cmek-key\""));
2366        assert!(json.contains("\"business_unit\""));
2367        assert!(json.contains("\"business_owners\""));
2368        assert!(json.contains("\"teams\""));
2369        assert!(json.contains("\"tags\""));
2370        assert!(json.contains("\"custom_fields\""));
2371
2372        // Verify deserialization works with full structure
2373        let parsed: CreateApplicationRequest =
2374            serde_json::from_str(&json).expect("should deserialize json");
2375        assert_eq!(
2376            parsed.profile.custom_kms_alias,
2377            Some("alias/production-cmek-key".to_string())
2378        );
2379        assert!(parsed.profile.business_unit.is_some());
2380        assert!(parsed.profile.business_owners.is_some());
2381    }
2382}
2383
2384#[cfg(test)]
2385#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2386mod proptests {
2387    use super::*;
2388    use proptest::prelude::*;
2389
2390    // Strategy for valid KMS aliases
2391    fn valid_kms_alias_strategy() -> impl Strategy<Value = String> {
2392        // Valid characters: alphanumeric, hyphen, underscore, forward slash
2393        // Length: 8-256 chars including "alias/" prefix
2394        // Must not start/end with "aws"
2395        prop::string::string_regex("[a-zA-Z0-9_/-]{2,250}")
2396            .expect("valid regex pattern for KMS alias")
2397            .prop_map(|s| format!("alias/{}", s))
2398            .prop_filter("Cannot start with aws", |s| {
2399                !s.strip_prefix("alias/").unwrap_or("").starts_with("aws")
2400            })
2401            .prop_filter("Cannot end with aws", |s| {
2402                !s.strip_prefix("alias/").unwrap_or("").ends_with("aws")
2403            })
2404    }
2405
2406    fn invalid_kms_alias_strategy() -> impl Strategy<Value = String> {
2407        prop_oneof![
2408            // Missing prefix
2409            prop::string::string_regex("[a-zA-Z0-9_/-]{5,20}")
2410                .expect("valid regex for missing prefix test"),
2411            // Wrong prefix
2412            Just("arn:aws:kms:us-east-1:123456789:alias/test".to_string()),
2413            // AWS reserved
2414            Just("alias/aws-managed".to_string()),
2415            Just("alias/test-aws".to_string()),
2416            // Empty after prefix
2417            Just("alias/".to_string()),
2418            // Too short
2419            Just("alias/a".to_string()),
2420            // Invalid characters
2421            Just("alias/test@key".to_string()),
2422            Just("alias/test key".to_string()),
2423            Just("alias/test.key".to_string()),
2424            // Too long
2425            prop::string::string_regex("[a-z]{252}")
2426                .expect("valid regex for too long test")
2427                .prop_map(|s| format!("alias/{}", s)),
2428        ]
2429    }
2430
2431    proptest! {
2432        #![proptest_config(ProptestConfig {
2433            cases: if cfg!(miri) { 5 } else { 1000 },
2434            failure_persistence: None, // Required for Miri compatibility
2435            .. ProptestConfig::default()
2436        })]
2437
2438        #[test]
2439        fn proptest_valid_kms_aliases_accepted(alias in valid_kms_alias_strategy()) {
2440            prop_assert!(validate_kms_alias(&alias).is_ok(),
2441                "Valid alias rejected: {}", alias);
2442        }
2443
2444        #[test]
2445        fn proptest_invalid_kms_aliases_rejected(alias in invalid_kms_alias_strategy()) {
2446            prop_assert!(validate_kms_alias(&alias).is_err(),
2447                "Invalid alias accepted: {}", alias);
2448        }
2449
2450        #[test]
2451        fn proptest_kms_alias_length_bounds(
2452            prefix in prop::string::string_regex("[a-zA-Z0-9_/-]{1,7}").expect("valid regex for prefix"),
2453            suffix in prop::string::string_regex("[a-zA-Z0-9_/-]{251,300}").expect("valid regex for suffix")
2454        ) {
2455            let too_short = format!("alias/{}", prefix);
2456            let too_long = format!("alias/{}", suffix);
2457
2458            prop_assert!(validate_kms_alias(&too_short).is_err() || too_short.len() >= 8,
2459                "Too short alias not rejected");
2460            prop_assert!(validate_kms_alias(&too_long).is_err(),
2461                "Too long alias not rejected");
2462        }
2463    }
2464}
2465
2466#[cfg(test)]
2467#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2468mod query_proptests {
2469    use super::*;
2470    use crate::validation::encode_query_param;
2471    use proptest::prelude::*;
2472
2473    proptest! {
2474        #![proptest_config(ProptestConfig {
2475            cases: if cfg!(miri) { 5 } else { 1000 },
2476            failure_persistence: None, // Required for Miri compatibility
2477            .. ProptestConfig::default()
2478        })]
2479
2480        #[test]
2481        fn proptest_query_param_no_injection(
2482            value in prop::string::string_regex(".{1,100}").expect("valid regex for query param")
2483        ) {
2484            let encoded = encode_query_param(&value);
2485
2486            // Encoded value should not contain raw injection characters
2487            prop_assert!(!encoded.contains('&'), "Ampersand not encoded");
2488            prop_assert!(!encoded.contains('=') || value.contains('=') && encoded.contains("%3D"),
2489                "Equals not encoded");
2490            prop_assert!(!encoded.contains(';'), "Semicolon not encoded");
2491        }
2492
2493        #[test]
2494        fn proptest_query_param_path_traversal_encoded(
2495            segments in prop::collection::vec(
2496                prop::string::string_regex("[a-zA-Z0-9]{1,10}").expect("valid regex for path segments"),
2497                1..5
2498            )
2499        ) {
2500            let path_traversal = segments.join("../");
2501            let encoded = encode_query_param(&path_traversal);
2502
2503            // Path traversal is prevented by encoding the separators, not the dots
2504            // The sequence "../" becomes "..%2F" which won't be interpreted as traversal
2505            prop_assert!(!encoded.contains("../"), "Path traversal sequence '../' not broken by encoding");
2506            prop_assert!(!encoded.contains("..\\"), "Path traversal sequence '..\\' not broken by encoding");
2507            prop_assert!(encoded.contains("%2F") || !path_traversal.contains('/'),
2508                "Forward slash not encoded");
2509        }
2510
2511        #[test]
2512        fn proptest_application_query_to_params_no_key_pollution(
2513            name in prop::option::of(prop::string::string_regex("[a-zA-Z0-9 &=;]{1,50}").expect("valid regex for app name")),
2514            compliance in prop::option::of(Just("PASSED".to_string())),
2515            page in prop::option::of(0u32..1000u32),
2516            size in prop::option::of(1u32..1000u32)
2517        ) {
2518            let mut query = ApplicationQuery::new();
2519            if let Some(n) = name {
2520                query = query.with_name(&n);
2521            }
2522            if let Some(c) = compliance {
2523                query = query.with_policy_compliance(&c);
2524            }
2525            query.page = page;
2526            query.size = size;
2527
2528            let params = query.to_query_params();
2529
2530            // Verify each key appears at most once
2531            let mut seen_keys = std::collections::HashSet::new();
2532            for (key, _) in params.iter() {
2533                prop_assert!(!seen_keys.contains(key),
2534                    "Duplicate parameter key: {}", key);
2535                seen_keys.insert(key.clone());
2536            }
2537        }
2538    }
2539}
2540
2541#[cfg(test)]
2542#[allow(clippy::expect_used)] // Test-only validation patterns are safe
2543mod pagination_proptests {
2544    use super::*;
2545    use crate::validation::{MAX_PAGE_NUMBER, MAX_PAGE_SIZE};
2546    use proptest::prelude::*;
2547
2548    proptest! {
2549        #![proptest_config(ProptestConfig {
2550            cases: if cfg!(miri) { 5 } else { 1000 },
2551            failure_persistence: None, // Required for Miri compatibility
2552            .. ProptestConfig::default()
2553        })]
2554
2555        #[test]
2556        fn proptest_page_size_bounds_enforced(size in 0u32..u32::MAX) {
2557            match validate_page_size(Some(size)) {
2558                Ok(validated) => {
2559                    prop_assert!(validated >= 1, "Zero page size accepted");
2560                    prop_assert!(validated <= MAX_PAGE_SIZE,
2561                        "Page size {} exceeds maximum {}", validated, MAX_PAGE_SIZE);
2562                }
2563                Err(_) => {
2564                    prop_assert_eq!(size, 0, "Non-zero size rejected");
2565                }
2566            }
2567        }
2568
2569        #[test]
2570        fn proptest_page_number_bounds_enforced(page in 0u32..u32::MAX) {
2571            let validated = validate_page_number(Some(page)).expect("page number validation should not fail");
2572
2573            if let Some(p) = validated {
2574                prop_assert!(p <= MAX_PAGE_NUMBER,
2575                    "Page number {} exceeds maximum {}", p, MAX_PAGE_NUMBER);
2576            }
2577        }
2578
2579        #[test]
2580        fn proptest_application_query_normalize_safety(
2581            page in prop::option::of(0u32..u32::MAX),
2582            size in prop::option::of(0u32..u32::MAX)
2583        ) {
2584            let mut query = ApplicationQuery::new();
2585            query.page = page;
2586            query.size = size;
2587
2588            match query.normalize() {
2589                Ok(normalized) => {
2590                    // If normalization succeeds, bounds must be enforced
2591                    if let Some(s) = normalized.size {
2592                        prop_assert!((1..=MAX_PAGE_SIZE).contains(&s),
2593                            "Normalized size {} out of bounds", s);
2594                    }
2595                    if let Some(p) = normalized.page {
2596                        prop_assert!(p <= MAX_PAGE_NUMBER,
2597                            "Normalized page {} exceeds maximum", p);
2598                    }
2599                }
2600                Err(_) => {
2601                    // Errors only for zero size
2602                    prop_assert_eq!(size, Some(0), "Unexpected normalization error");
2603                }
2604            }
2605        }
2606    }
2607}
2608#[cfg(test)]
2609mod miri_tests {
2610    use super::*;
2611
2612    #[test]
2613    fn miri_business_owner_debug_redaction() {
2614        let owner = BusinessOwner {
2615            email: Some("sensitive@example.com".to_string()),
2616            name: Some("Sensitive Name".to_string()),
2617        };
2618
2619        let debug_str = format!("{:?}", owner);
2620
2621        // Verify redaction occurred
2622        assert!(debug_str.contains("[REDACTED]"));
2623        assert!(!debug_str.contains("sensitive@example.com"));
2624        assert!(!debug_str.contains("Sensitive Name"));
2625    }
2626
2627    #[test]
2628    fn miri_business_owner_none_fields() {
2629        let owner = BusinessOwner {
2630            email: None,
2631            name: None,
2632        };
2633
2634        // Should not panic or exhibit UB with None fields
2635        let debug_str = format!("{:?}", owner);
2636        assert!(debug_str.contains("[REDACTED]"));
2637    }
2638
2639    #[test]
2640    fn miri_custom_field_debug_redaction() {
2641        let field = CustomField {
2642            name: Some("API_KEY".to_string()),
2643            value: Some("super-secret-key".to_string()),
2644        };
2645
2646        let debug_str = format!("{:?}", field);
2647
2648        // Verify value is redacted but name is visible
2649        assert!(debug_str.contains("API_KEY"));
2650        assert!(debug_str.contains("[REDACTED]"));
2651        assert!(!debug_str.contains("super-secret-key"));
2652    }
2653
2654    #[test]
2655    fn miri_custom_field_none_value() {
2656        let field = CustomField {
2657            name: Some("EMPTY_FIELD".to_string()),
2658            value: None,
2659        };
2660
2661        // Should not panic with None value
2662        let debug_str = format!("{:?}", field);
2663        assert!(debug_str.contains("EMPTY_FIELD"));
2664        assert!(debug_str.contains("[REDACTED]"));
2665    }
2666}
2667
2668#[cfg(test)]
2669mod miri_validation_tests {
2670    use super::*;
2671
2672    #[test]
2673    fn miri_app_name_utf8_boundaries() {
2674        // Test with various UTF-8 characters
2675        let emoji_name = "MyApp 🚀 Test";
2676        let result = AppName::new(emoji_name);
2677        assert!(result.is_ok());
2678
2679        // Test with combining characters
2680        let combining = "Café"; // é is a combining character
2681        let result = AppName::new(combining);
2682        assert!(result.is_ok());
2683    }
2684
2685    #[test]
2686    fn miri_description_null_byte_handling() {
2687        // Ensure null byte check doesn't cause UB
2688        let with_null = "test\0value";
2689        let result = Description::new(with_null);
2690        assert!(result.is_err());
2691
2692        // Verify error type
2693        if let Err(err) = result {
2694            assert!(matches!(err, ValidationError::NullByteInDescription));
2695        }
2696    }
2697
2698    #[test]
2699    fn miri_kms_alias_character_iteration() {
2700        // Test character iteration doesn't violate memory safety
2701        let test_cases = vec![
2702            "alias/test-key",
2703            "alias/test_key_2024",
2704            "alias/app/prod/key",
2705            "alias/UPPERCASE_KEY",
2706        ];
2707
2708        for alias in test_cases {
2709            let _ = validate_kms_alias(alias);
2710        }
2711    }
2712}
2713
2714#[cfg(test)]
2715#[allow(clippy::expect_used)] // Test-only hardcoded regexes are safe
2716mod miri_proptest {
2717    use super::*;
2718    use proptest::prelude::*;
2719
2720    proptest! {
2721        #![proptest_config(ProptestConfig {
2722            cases: if cfg!(miri) { 5 } else { 1000 },
2723            failure_persistence: None, // Required for Miri compatibility
2724            .. ProptestConfig::default()
2725        })]
2726
2727        #[test]
2728        fn miri_proptest_app_name_utf8_safety(
2729            s in prop::string::string_regex("[\\p{L}\\p{N} ]{1,50}").expect("valid regex")
2730        ) {
2731            let _ = AppName::new(&s);
2732            // Miri will catch any UTF-8 boundary violations
2733        }
2734    }
2735}