1use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
6use crate::{VeracodeClient, VeracodeError, VeracodeRegion};
7use async_stream::try_stream;
8use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
9use chrono_tz::America::New_York;
10use chrono_tz::Europe::Berlin;
11use futures_core::stream::Stream;
12use serde::{Deserialize, Serialize};
13use urlencoding;
14
15#[derive(Debug, Clone, Serialize)]
17pub struct AuditReportRequest {
18 pub report_type: String,
20 pub start_date: String,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub end_date: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub audit_action: Option<Vec<String>>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub action_type: Option<Vec<String>>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub target_user_id: Option<Vec<String>>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub modifier_user_id: Option<Vec<String>>,
37}
38
39impl AuditReportRequest {
40 #[must_use]
42 pub fn new(start_date: impl Into<String>, end_date: Option<String>) -> Self {
43 Self {
44 report_type: "AUDIT".to_string(),
45 start_date: start_date.into(),
46 end_date,
47 audit_action: None,
48 action_type: None,
49 target_user_id: None,
50 modifier_user_id: None,
51 }
52 }
53
54 #[must_use]
56 pub fn with_audit_actions(mut self, actions: Vec<String>) -> Self {
57 self.audit_action = Some(actions);
58 self
59 }
60
61 #[must_use]
63 pub fn with_action_types(mut self, types: Vec<String>) -> Self {
64 self.action_type = Some(types);
65 self
66 }
67
68 #[must_use]
70 pub fn with_target_users(mut self, user_ids: Vec<String>) -> Self {
71 self.target_user_id = Some(user_ids);
72 self
73 }
74
75 #[must_use]
77 pub fn with_modifier_users(mut self, user_ids: Vec<String>) -> Self {
78 self.modifier_user_id = Some(user_ids);
79 self
80 }
81}
82
83#[derive(Debug, Clone, Deserialize)]
85pub struct GenerateReportData {
86 pub id: String,
88}
89
90#[derive(Debug, Clone, Deserialize)]
92pub struct GenerateReportResponse {
93 #[serde(rename = "_embedded")]
95 pub embedded: GenerateReportData,
96}
97
98#[derive(Debug, Clone, PartialEq, Deserialize)]
100#[serde(rename_all = "UPPERCASE")]
101pub enum ReportStatus {
102 Queued,
104 Submitted,
106 Processing,
108 Completed,
110 Failed,
112}
113
114impl std::fmt::Display for ReportStatus {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 ReportStatus::Queued => write!(f, "Queued"),
118 ReportStatus::Submitted => write!(f, "Submitted"),
119 ReportStatus::Processing => write!(f, "Processing"),
120 ReportStatus::Completed => write!(f, "Completed"),
121 ReportStatus::Failed => write!(f, "Failed"),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Deserialize)]
128pub struct ReportLinks {
129 pub first: Option<LinkHref>,
131 pub prev: Option<LinkHref>,
133 #[serde(rename = "self")]
135 pub self_link: Option<LinkHref>,
136 pub next: Option<LinkHref>,
138 pub last: Option<LinkHref>,
140}
141
142#[derive(Debug, Clone, Deserialize)]
144pub struct LinkHref {
145 pub href: String,
147}
148
149#[derive(Debug, Clone, Deserialize)]
151pub struct PageMetadata {
152 pub number: u32,
154 pub size: u32,
156 pub total_elements: u32,
158 pub total_pages: u32,
160}
161
162#[derive(Debug, Clone, Serialize)]
168pub struct AuditLogEntry {
169 pub raw_log: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
178 pub timestamp_utc: Option<String>,
179 pub log_hash: String,
181}
182
183#[derive(Debug, Deserialize)]
185struct TimestampExtractor {
186 timestamp: Option<String>,
187}
188
189#[derive(Debug, Clone, Deserialize)]
191pub struct ReportData {
192 pub id: String,
194 pub report_type: String,
196 pub status: ReportStatus,
198 pub requested_by_user: String,
200 pub requested_by_account: u64,
202 pub date_report_requested: String,
204 pub date_report_completed: Option<String>,
206 pub report_expiration_date: Option<String>,
208 pub audit_logs: serde_json::Value,
210 #[serde(rename = "_links")]
212 pub links: Option<ReportLinks>,
213 pub page_metadata: Option<PageMetadata>,
215}
216
217#[derive(Debug, Clone, Deserialize)]
219pub struct ReportResponse {
220 #[serde(rename = "_embedded")]
222 pub embedded: ReportData,
223}
224
225fn convert_regional_timestamp_to_utc(
273 timestamp_str: &str,
274 region: &VeracodeRegion,
275) -> Option<String> {
276 let has_millis = timestamp_str.contains('.');
278
279 let naive_dt = if has_millis {
281 NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.f").ok()?
284 } else {
285 NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S").ok()?
286 };
287
288 let utc_time = match region {
290 VeracodeRegion::European => {
291 let regional_time: DateTime<_> = Berlin.from_local_datetime(&naive_dt).earliest()?;
294 regional_time.with_timezone(&Utc)
295 }
296 VeracodeRegion::Commercial | VeracodeRegion::Federal => {
297 let regional_time: DateTime<_> = New_York.from_local_datetime(&naive_dt).earliest()?;
300 regional_time.with_timezone(&Utc)
301 }
302 };
303
304 if has_millis {
306 let formatted = utc_time.format("%Y-%m-%d %H:%M:%S%.f").to_string();
308 Some(formatted)
310 } else {
311 Some(utc_time.format("%Y-%m-%d %H:%M:%S").to_string())
312 }
313}
314
315fn generate_log_hash(raw_json: &str) -> String {
346 use xxhash_rust::xxh3::xxh3_128;
347
348 let hash = xxh3_128(raw_json.as_bytes());
350
351 format!("{:032x}", hash)
353}
354
355fn sort_log_values_by_timestamp(logs: &mut [serde_json::Value]) {
357 logs.sort_by(|a, b| {
358 let ts_a = a.get("timestamp_utc").and_then(|v| v.as_str());
359 let ts_b = b.get("timestamp_utc").and_then(|v| v.as_str());
360 match (ts_a, ts_b) {
361 (Some(ta), Some(tb)) => ta.cmp(tb),
362 (Some(_), None) => std::cmp::Ordering::Less,
363 (None, Some(_)) => std::cmp::Ordering::Greater,
364 (None, None) => std::cmp::Ordering::Equal,
365 }
366 });
367}
368
369#[derive(Clone)]
371pub struct ReportingApi {
372 client: VeracodeClient,
373 region: VeracodeRegion,
374}
375
376impl ReportingApi {
377 #[must_use]
379 pub fn new(client: VeracodeClient) -> Self {
380 let region = client.config().region;
381 Self { client, region }
382 }
383
384 pub async fn generate_audit_report(
406 &self,
407 request: &AuditReportRequest,
408 ) -> Result<String, VeracodeError> {
409 let response = self
410 .client
411 .post("/appsec/v1/analytics/report", Some(request))
412 .await?;
413
414 let response_text = response.text().await?;
415 log::debug!("Generate report API response: {}", response_text);
416
417 validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
419 VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
420 })?;
421
422 let generate_response: GenerateReportResponse = serde_json::from_str(&response_text)?;
423 Ok(generate_response.embedded.id)
424 }
425
426 pub async fn get_audit_report(
444 &self,
445 report_id: &str,
446 page: Option<u32>,
447 ) -> Result<ReportResponse, VeracodeError> {
448 let encoded_report_id = urlencoding::encode(report_id);
450
451 let endpoint = if let Some(page_num) = page {
452 format!("/appsec/v1/analytics/report/{encoded_report_id}?page={page_num}")
453 } else {
454 format!("/appsec/v1/analytics/report/{encoded_report_id}")
455 };
456
457 let response = self.client.get(&endpoint, None).await?;
458 let response_text = response.text().await?;
459 log::debug!("Get audit report API response: {}", response_text);
460
461 validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
463 VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
464 })?;
465
466 let report_response: ReportResponse = serde_json::from_str(&response_text)?;
467 Ok(report_response)
468 }
469
470 pub async fn poll_report_status(
489 &self,
490 report_id: &str,
491 max_attempts: Option<u32>,
492 initial_delay_secs: Option<u64>,
493 ) -> Result<ReportResponse, VeracodeError> {
494 let max_attempts = max_attempts.unwrap_or(30);
495 let initial_delay = initial_delay_secs.unwrap_or(2);
496
497 let mut attempts: u32 = 0;
498 let mut delay_secs = initial_delay;
499
500 loop {
501 attempts = attempts.saturating_add(1);
502
503 let report = self.get_audit_report(report_id, None).await?;
505 let status = &report.embedded.status;
506
507 log::debug!(
508 "Report {} status: {} (attempt {}/{})",
509 report_id,
510 status,
511 attempts,
512 max_attempts
513 );
514
515 match status {
516 ReportStatus::Completed => {
517 log::info!("Report {} completed successfully", report_id);
518 return Ok(report);
519 }
520 ReportStatus::Failed => {
521 return Err(VeracodeError::InvalidResponse(format!(
522 "Report generation failed for report ID: {}",
523 report_id
524 )));
525 }
526 ReportStatus::Queued | ReportStatus::Submitted | ReportStatus::Processing => {
527 if attempts >= max_attempts {
528 return Err(VeracodeError::InvalidResponse(format!(
529 "Report polling timeout after {} attempts. Status: {}",
530 attempts, status
531 )));
532 }
533
534 log::debug!("Report still processing, waiting {} seconds...", delay_secs);
535 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
536
537 delay_secs = std::cmp::min(delay_secs.saturating_mul(2), 30);
539 }
540 }
541 }
542 }
543
544 pub async fn get_all_audit_log_pages(
567 &self,
568 report_id: &str,
569 ) -> Result<Vec<AuditLogEntry>, VeracodeError> {
570 let mut all_logs = Vec::new();
571
572 let initial_report = self.get_audit_report(report_id, None).await?;
574
575 if initial_report.embedded.status != ReportStatus::Completed {
577 return Err(VeracodeError::InvalidResponse(format!(
578 "Report is not completed. Status: {}",
579 initial_report.embedded.status
580 )));
581 }
582
583 let page_metadata = match initial_report.embedded.page_metadata {
585 Some(metadata) if metadata.total_elements > 0 => metadata,
586 Some(metadata) => {
587 log::info!(
589 "Report completed but contains no audit log entries (0 total elements, {} total pages)",
590 metadata.total_pages
591 );
592 return Ok(all_logs); }
594 None => {
595 log::info!("Report completed but contains no audit log entries (no page metadata)");
597 return Ok(all_logs); }
599 };
600
601 let mut all_pages_raw = Vec::new();
603
604 let first_page = self.get_audit_report(report_id, Some(0)).await?;
606 all_pages_raw.push(first_page.embedded.audit_logs.clone());
607
608 log::info!(
609 "Retrieved page 1/{} ({} total)",
610 page_metadata.total_pages,
611 page_metadata.total_elements
612 );
613
614 if page_metadata.total_pages > 1 {
616 for page_num in 1..page_metadata.total_pages {
617 log::debug!(
618 "Retrieving page {}/{}",
619 page_num.saturating_add(1),
620 page_metadata.total_pages
621 );
622
623 let page_response = self.get_audit_report(report_id, Some(page_num)).await?;
624 all_pages_raw.push(page_response.embedded.audit_logs.clone());
625
626 log::info!(
627 "Retrieved page {}/{}",
628 page_num.saturating_add(1),
629 page_metadata.total_pages
630 );
631 }
632 }
633
634 let mut conversion_stats: (u32, u32) = (0, 0); let mut serialization_stats: (u32, u32) = (0, 0); let mut total_entries: u32 = 0;
638
639 for page_value in all_pages_raw {
640 if let Some(logs_array) = page_value.as_array() {
641 for log_value in logs_array {
642 total_entries = total_entries.saturating_add(1);
643
644 let raw_log = match serde_json::to_string(log_value) {
646 Ok(json_str) => {
647 serialization_stats.0 = serialization_stats.0.saturating_add(1);
648 json_str
649 }
650 Err(e) => {
651 log::error!(
652 "Failed to serialize audit log entry {}: {}. Entry will be replaced with empty object.",
653 total_entries,
654 e
655 );
656 serialization_stats.1 = serialization_stats.1.saturating_add(1);
657 "{}".to_string()
658 }
659 };
660
661 let log_hash = generate_log_hash(&raw_log);
663
664 let timestamp_utc = if let Ok(extractor) =
666 serde_json::from_value::<TimestampExtractor>(log_value.clone())
667 {
668 if let Some(timestamp) = extractor.timestamp {
669 match convert_regional_timestamp_to_utc(×tamp, &self.region) {
670 Some(utc) => {
671 conversion_stats.0 = conversion_stats.0.saturating_add(1);
672 Some(utc)
673 }
674 None => {
675 log::warn!("Failed to convert timestamp to UTC: {}", timestamp);
676 conversion_stats.1 = conversion_stats.1.saturating_add(1);
677 None
678 }
679 }
680 } else {
681 None
682 }
683 } else {
684 None
685 };
686
687 all_logs.push(AuditLogEntry {
689 raw_log,
690 timestamp_utc,
691 log_hash,
692 });
693 }
694 }
695 }
696
697 log::info!(
698 "Successfully processed {} audit log entries across {} pages",
699 total_entries,
700 page_metadata.total_pages
701 );
702
703 let (region_name, source_timezone) = match self.region {
704 VeracodeRegion::Commercial => (
705 "Commercial (api.veracode.com)",
706 "America/New_York (EST/EDT, UTC-5/-4)",
707 ),
708 VeracodeRegion::European => (
709 "European (api.veracode.eu)",
710 "Europe/Berlin (CET/CEST, UTC+1/+2)",
711 ),
712 VeracodeRegion::Federal => (
713 "Federal (api.veracode.us)",
714 "America/New_York (EST/EDT, UTC-5/-4)",
715 ),
716 };
717
718 log::info!(
719 "Converted {} timestamps from {} to UTC - Region: {} ({} failures)",
720 conversion_stats.0,
721 source_timezone,
722 region_name,
723 conversion_stats.1
724 );
725
726 log::info!(
727 "Generated xxHash hashes for {} log entries (optimized: 10-50x faster than SHA256, zero cloning)",
728 total_entries
729 );
730
731 if serialization_stats.1 > 0 {
732 log::warn!(
733 "Serialization statistics: {} successful, {} failed (replaced with empty objects)",
734 serialization_stats.0,
735 serialization_stats.1
736 );
737 } else {
738 log::info!(
739 "Serialization statistics: {} successful, 0 failed",
740 serialization_stats.0
741 );
742 }
743
744 Ok(all_logs)
745 }
746
747 pub async fn get_audit_logs(
764 &self,
765 request: &AuditReportRequest,
766 ) -> Result<serde_json::Value, VeracodeError> {
767 log::info!(
769 "Generating audit report for date range: {} to {}",
770 request.start_date,
771 request.end_date.as_deref().unwrap_or("now")
772 );
773 let report_id = self.generate_audit_report(request).await?;
774 log::info!("Report generated with ID: {}", report_id);
775
776 log::info!("Polling for report completion...");
778 let completed_report = self.poll_report_status(&report_id, None, None).await?;
779 log::info!(
780 "Report completed at: {}",
781 completed_report
782 .embedded
783 .date_report_completed
784 .as_deref()
785 .unwrap_or("unknown")
786 );
787
788 log::info!("Retrieving all audit log pages...");
790 let mut all_logs = self.get_all_audit_log_pages(&report_id).await?;
791
792 log::info!(
794 "Sorting {} audit logs by timestamp (oldest to newest)...",
795 all_logs.len()
796 );
797 all_logs.sort_by(|a, b| {
798 match (&a.timestamp_utc, &b.timestamp_utc) {
799 (Some(ts_a), Some(ts_b)) => {
801 let parsed_a = NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S%.f")
804 .or_else(|_| NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S"));
805 let parsed_b = NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S%.f")
806 .or_else(|_| NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S"));
807
808 match (parsed_a, parsed_b) {
809 (Ok(dt_a), Ok(dt_b)) => dt_a.cmp(&dt_b), (Ok(_), Err(_)) => std::cmp::Ordering::Less, (Err(_), Ok(_)) => std::cmp::Ordering::Greater, (Err(_), Err(_)) => std::cmp::Ordering::Equal, }
814 }
815 (Some(_), None) => std::cmp::Ordering::Less,
817 (None, Some(_)) => std::cmp::Ordering::Greater,
819 (None, None) => std::cmp::Ordering::Equal,
821 }
822 });
823 log::info!("Logs sorted successfully (oldest to newest)");
824
825 let json_logs = serde_json::to_value(&all_logs)?;
827 log::info!(
828 "Successfully retrieved {} total audit log entries",
829 all_logs.len()
830 );
831
832 Ok(json_logs)
833 }
834
835 pub fn get_audit_logs_stream(
854 &self,
855 request: AuditReportRequest,
856 flush_threshold_bytes: usize,
857 ) -> impl Stream<Item = Result<Vec<serde_json::Value>, VeracodeError>> {
858 let api = self.clone();
859
860 try_stream! {
861 log::info!(
863 "Generating audit report for date range: {} to {}",
864 request.start_date,
865 request.end_date.as_deref().unwrap_or("now")
866 );
867 let report_id = api.generate_audit_report(&request).await?;
868 log::info!("Report generated with ID: {}", report_id);
869
870 log::info!("Polling for report completion...");
872 let completed_report = api.poll_report_status(&report_id, None, None).await?;
873 log::info!(
874 "Report completed at: {}",
875 completed_report.embedded.date_report_completed.as_deref().unwrap_or("unknown")
876 );
877
878 let initial = api.get_audit_report(&report_id, None).await?;
880 let page_metadata = match initial.embedded.page_metadata {
881 Some(ref m) if m.total_elements > 0 => m.clone(),
882 Some(ref m) => {
883 log::info!(
884 "Report completed but contains no audit log entries ({} total pages)",
885 m.total_pages
886 );
887 return;
888 }
889 None => {
890 log::info!("Report completed but contains no audit log entries (no page metadata)");
891 return;
892 }
893 };
894
895 log::info!(
896 "Streaming {} entries across {} pages (flush threshold: {} bytes)",
897 page_metadata.total_elements,
898 page_metadata.total_pages,
899 flush_threshold_bytes
900 );
901
902 let mut buffer: Vec<serde_json::Value> = Vec::new();
903 let mut buffer_bytes: usize = 0;
904 let mut batch_num: usize = 0;
905
906 for page_num in 0..page_metadata.total_pages {
908 let page = api.get_audit_report(&report_id, Some(page_num)).await?;
909 log::debug!(
910 "Processing page {}/{}",
911 page_num.saturating_add(1),
912 page_metadata.total_pages
913 );
914
915 if let Some(logs) = page.embedded.audit_logs.as_array() {
916 for log_value in logs {
917 let raw_log = match serde_json::to_string(log_value) {
919 Ok(s) => s,
920 Err(e) => {
921 log::error!("Failed to serialize log entry: {}", e);
922 "{}".to_string()
923 }
924 };
925
926 let log_hash = generate_log_hash(&raw_log);
928
929 let timestamp_utc = serde_json::from_value::<TimestampExtractor>(log_value.clone())
931 .ok()
932 .and_then(|e| e.timestamp)
933 .and_then(|ts| convert_regional_timestamp_to_utc(&ts, &api.region));
934
935 let entry = AuditLogEntry { raw_log, timestamp_utc, log_hash };
936 let entry_value = serde_json::to_value(&entry)?;
937
938 let entry_size = entry_value.to_string().len();
940 buffer_bytes = buffer_bytes.saturating_add(entry_size);
941 buffer.push(entry_value);
942
943 if buffer_bytes >= flush_threshold_bytes {
945 batch_num = batch_num.saturating_add(1);
946 sort_log_values_by_timestamp(&mut buffer);
947 log::info!(
948 "Flushing batch {} ({} entries, ~{} bytes)",
949 batch_num,
950 buffer.len(),
951 buffer_bytes
952 );
953 let batch = std::mem::take(&mut buffer);
954 buffer_bytes = 0;
955 yield batch;
956 }
957 }
958 }
959 }
960
961 if !buffer.is_empty() {
963 batch_num = batch_num.saturating_add(1);
964 sort_log_values_by_timestamp(&mut buffer);
965 log::info!(
966 "Flushing final batch {} ({} entries, ~{} bytes)",
967 batch_num,
968 buffer.len(),
969 buffer_bytes
970 );
971 yield buffer;
972 }
973 }
974 }
975}
976
977#[derive(Debug, thiserror::Error)]
979#[must_use = "Need to handle all error enum types."]
980pub enum ReportingError {
981 #[error("Veracode API error: {0}")]
983 VeracodeApi(#[from] VeracodeError),
984
985 #[error("Invalid date format: {0}")]
987 InvalidDate(String),
988
989 #[error("Date range exceeds maximum allowed: {0}")]
991 DateRangeExceeded(String),
992}
993
994#[cfg(test)]
995#[allow(clippy::expect_used)]
996mod tests {
997 use super::*;
998
999 #[test]
1000 fn test_audit_report_request_new() {
1001 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
1002
1003 assert_eq!(request.report_type, "AUDIT");
1004 assert_eq!(request.start_date, "2025-01-01");
1005 assert_eq!(request.end_date, Some("2025-01-31".to_string()));
1006 assert!(request.audit_action.is_none());
1007 assert!(request.action_type.is_none());
1008 }
1009
1010 #[test]
1011 fn test_audit_report_request_with_filters() {
1012 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()))
1013 .with_audit_actions(vec!["Delete".to_string(), "Create".to_string()])
1014 .with_action_types(vec!["Admin".to_string()]);
1015
1016 assert_eq!(
1017 request.audit_action,
1018 Some(vec!["Delete".to_string(), "Create".to_string()])
1019 );
1020 assert_eq!(request.action_type, Some(vec!["Admin".to_string()]));
1021 }
1022
1023 #[test]
1024 fn test_audit_report_request_serialization() {
1025 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
1026 let json = serde_json::to_string(&request).expect("should serialize to json");
1027
1028 assert!(json.contains("\"report_type\":\"AUDIT\""));
1029 assert!(json.contains("\"start_date\":\"2025-01-01\""));
1030 assert!(json.contains("\"end_date\":\"2025-01-31\""));
1031 }
1032
1033 #[test]
1034 fn test_audit_report_request_serialization_without_optional_fields() {
1035 let request = AuditReportRequest::new("2025-01-01", None);
1036 let json = serde_json::to_string(&request).expect("should serialize to json");
1037
1038 assert!(!json.contains("end_date"));
1040 assert!(!json.contains("audit_action"));
1041 assert!(!json.contains("action_type"));
1042 }
1043
1044 #[test]
1045 fn test_convert_european_timezone_winter() {
1046 let result =
1048 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.000", &VeracodeRegion::European);
1049 assert!(result.is_some());
1050 assert_eq!(
1053 result.expect("should convert timestamp"),
1054 "2025-01-15 09:00:00"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_convert_european_timezone_summer() {
1060 let result =
1062 convert_regional_timestamp_to_utc("2025-06-15 10:00:00.000", &VeracodeRegion::European);
1063 assert!(result.is_some());
1064 assert_eq!(
1066 result.expect("should convert timestamp"),
1067 "2025-06-15 08:00:00"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_convert_commercial_timezone_winter() {
1073 let result = convert_regional_timestamp_to_utc(
1075 "2025-01-15 14:30:00.000",
1076 &VeracodeRegion::Commercial,
1077 );
1078 assert!(result.is_some());
1079 assert_eq!(
1081 result.expect("should convert timestamp"),
1082 "2025-01-15 19:30:00"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_convert_commercial_timezone_summer() {
1088 let result = convert_regional_timestamp_to_utc(
1090 "2025-06-15 14:30:00.000",
1091 &VeracodeRegion::Commercial,
1092 );
1093 assert!(result.is_some());
1094 assert_eq!(
1096 result.expect("should convert timestamp"),
1097 "2025-06-15 18:30:00"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_convert_federal_timezone_winter() {
1103 let result =
1105 convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Federal);
1106 assert!(result.is_some());
1107 assert_eq!(
1109 result.expect("should convert timestamp"),
1110 "2025-12-15 19:30:00"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_convert_timezone_without_milliseconds() {
1116 let result =
1118 convert_regional_timestamp_to_utc("2025-01-15 10:00:00", &VeracodeRegion::European);
1119 assert!(result.is_some());
1120 assert_eq!(
1122 result.expect("should convert timestamp"),
1123 "2025-01-15 09:00:00"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_convert_timezone_variable_milliseconds() {
1129 let result =
1131 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.1", &VeracodeRegion::European);
1132 assert!(result.is_some());
1133
1134 let result =
1135 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.12", &VeracodeRegion::European);
1136 assert!(result.is_some());
1137
1138 let result = convert_regional_timestamp_to_utc(
1139 "2025-01-15 10:00:00.123456",
1140 &VeracodeRegion::European,
1141 );
1142 assert!(result.is_some());
1143 }
1144
1145 #[test]
1146 fn test_convert_timezone_invalid_format() {
1147 let result = convert_regional_timestamp_to_utc("invalid", &VeracodeRegion::European);
1149 assert!(result.is_none());
1150
1151 let result =
1152 convert_regional_timestamp_to_utc("2025-13-45 25:99:99", &VeracodeRegion::Commercial);
1153 assert!(result.is_none());
1154 }
1155
1156 #[test]
1157 fn test_convert_timezone_dst_fallback_ambiguous() {
1158 let result =
1163 convert_regional_timestamp_to_utc("2028-11-05 01:00:00", &VeracodeRegion::Commercial);
1164 assert!(
1165 result.is_some(),
1166 "Should handle DST fall-back ambiguous time"
1167 );
1168
1169 let utc = result.expect("result should be Some as asserted above");
1171 assert!(utc.len() >= 19, "UTC timestamp should be well-formed");
1172 assert!(utc.starts_with("2028-11-05"), "Date should be preserved");
1173 }
1174
1175 mod security_tests {
1180 use super::*;
1181 use proptest::prelude::*;
1182
1183 proptest! {
1191 #![proptest_config(ProptestConfig {
1192 cases: if cfg!(miri) { 5 } else { 1000 },
1193 failure_persistence: None,
1194 .. ProptestConfig::default()
1195 })]
1196
1197 #[test]
1200 fn proptest_valid_timestamp_conversion(
1201 year in 2000u32..2100u32,
1202 month in 1u32..=12u32,
1203 day in 1u32..=28u32, hour in prop::sample::select(vec![0u32, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]), minute in 0u32..=59u32,
1206 second in 0u32..=59u32,
1207 region in prop_oneof![
1208 Just(VeracodeRegion::Commercial),
1209 Just(VeracodeRegion::European),
1210 Just(VeracodeRegion::Federal),
1211 ]
1212 ) {
1213 let timestamp = format!(
1214 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
1215 year, month, day, hour, minute, second
1216 );
1217
1218 let result = convert_regional_timestamp_to_utc(×tamp, ®ion);
1219
1220 prop_assert!(result.is_some(), "Failed to convert valid timestamp: {}", timestamp);
1222
1223 if let Some(utc) = result {
1225 prop_assert!(utc.len() >= 19, "UTC timestamp too short: {}", utc);
1226 prop_assert!(utc.contains('-'), "UTC timestamp missing date separator");
1227 prop_assert!(utc.contains(':'), "UTC timestamp missing time separator");
1228 }
1229 }
1230
1231 #[test]
1234 fn proptest_malformed_timestamp_handling(
1235 input in "\\PC{0,256}", ) {
1237 let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::Commercial);
1239 let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::European);
1240 let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::Federal);
1241 }
1242
1243 #[test]
1246 fn proptest_variable_millisecond_precision(
1247 milliseconds in "[0-9]{1,9}",
1248 ) {
1249 let timestamp = format!("2025-06-15 10:30:45.{}", milliseconds);
1250 let result = convert_regional_timestamp_to_utc(×tamp, &VeracodeRegion::Commercial);
1251
1252 if let Some(utc) = result {
1255 prop_assert!(utc.len() >= 19, "UTC timestamp too short");
1256 }
1257 }
1258
1259 #[test]
1262 fn proptest_extreme_dates(
1263 year in 1900u32..2200u32,
1264 month in 0u32..=13u32, day in 0u32..=32u32, ) {
1267 let timestamp = format!(
1268 "{:04}-{:02}-{:02} 12:00:00",
1269 year, month, day
1270 );
1271
1272 let _ = convert_regional_timestamp_to_utc(×tamp, &VeracodeRegion::Commercial);
1274 }
1275 }
1276
1277 proptest! {
1284 #![proptest_config(ProptestConfig {
1285 cases: if cfg!(miri) { 5 } else { 1000 },
1286 failure_persistence: None,
1287 .. ProptestConfig::default()
1288 })]
1289
1290 #[test]
1293 fn proptest_hash_format_consistency(
1294 input in "\\PC{0,1024}", ) {
1296 let hash = generate_log_hash(&input);
1297
1298 prop_assert_eq!(hash.len(), 32, "Hash length should be 32 chars");
1300
1301 prop_assert!(
1303 hash.chars().all(|c| c.is_ascii_hexdigit()),
1304 "Hash should only contain hex chars: {}",
1305 hash
1306 );
1307 }
1308
1309 #[test]
1312 fn proptest_hash_determinism(
1313 input in "\\PC{0,2048}",
1314 ) {
1315 let hash1 = generate_log_hash(&input);
1316 let hash2 = generate_log_hash(&input);
1317
1318 prop_assert_eq!(
1319 hash1, hash2,
1320 "Hash function should be deterministic"
1321 );
1322 }
1323
1324 #[test]
1327 fn proptest_hash_collision_resistance(
1328 input1 in "\\PC{1,256}",
1329 input2 in "\\PC{1,256}",
1330 ) {
1331 if input1 != input2 {
1333 let hash1 = generate_log_hash(&input1);
1334 let hash2 = generate_log_hash(&input2);
1335
1336 prop_assert_ne!(
1339 hash1, hash2,
1340 "Collision detected for different inputs"
1341 );
1342 }
1343 }
1344
1345 #[test]
1348 fn proptest_hash_avalanche_effect(
1349 base in "[a-zA-Z0-9]{10,100}",
1350 suffix in "[a-z]",
1351 ) {
1352 let input1 = base.clone();
1353 let input2 = format!("{}{}", base, suffix);
1354
1355 let hash1 = generate_log_hash(&input1);
1356 let hash2 = generate_log_hash(&input2);
1357
1358 prop_assert_ne!(
1360 &hash1, &hash2,
1361 "Avalanche effect failed: similar inputs produced similar hashes"
1362 );
1363
1364 let diff_count = hash1.chars()
1366 .zip(hash2.chars())
1367 .filter(|(a, b)| a != b)
1368 .count();
1369
1370 prop_assert!(
1372 diff_count >= 12,
1373 "Poor avalanche effect: only {} of 32 chars changed",
1374 diff_count
1375 );
1376 }
1377 }
1378
1379 proptest! {
1386 #![proptest_config(ProptestConfig {
1387 cases: if cfg!(miri) { 5 } else { 1000 },
1388 failure_persistence: None,
1389 .. ProptestConfig::default()
1390 })]
1391
1392 #[test]
1395 fn proptest_url_encoding_escapes_special_chars(
1396 special_chars in prop::sample::select(vec![
1397 "/", "\\", "?", "&", "=", "#", " ", "<", ">",
1398 "\"", "'", "|", ";", "\n", "\r", "\0", "$"
1399 ]),
1400 base in "[a-zA-Z0-9]{5,20}",
1401 ) {
1402 let malicious_id = format!("{}{}{}", base, special_chars, base);
1403 let encoded = urlencoding::encode(&malicious_id);
1404
1405 prop_assert!(
1408 !encoded.contains(special_chars),
1409 "Special character '{}' not encoded properly",
1410 special_chars
1411 );
1412
1413 if !special_chars.chars().all(|c| c.is_alphanumeric()) {
1415 prop_assert!(
1416 encoded.contains('%') || (special_chars == " " && encoded.contains('+')),
1417 "Expected encoding for '{}'",
1418 special_chars
1419 );
1420 }
1421 }
1422
1423 #[test]
1426 fn proptest_url_encoding_prevents_path_traversal(
1427 traversal in prop_oneof![
1428 Just("../"),
1429 Just("..\\"),
1430 Just("../../"),
1431 Just("..%2f"),
1432 Just("..%5c"),
1433 Just("%2e%2e%2f"),
1434 ],
1435 prefix in "[a-z]{1,10}",
1436 suffix in "[a-z]{1,10}",
1437 ) {
1438 let malicious_id = format!("{}{}{}", prefix, traversal, suffix);
1439 let encoded = urlencoding::encode(&malicious_id);
1440
1441 prop_assert!(
1443 !encoded.contains("../") && !encoded.contains("..\\"),
1444 "Path traversal not properly encoded: {}",
1445 encoded
1446 );
1447 }
1448
1449 #[test]
1452 fn proptest_url_encoding_prevents_command_injection(
1453 injection_char in prop::sample::select(vec![
1454 ";", "|", "&", "$", "`", "$(", ")", "{", "}", "\n", "\r"
1455 ]),
1456 base in "[a-zA-Z0-9]{5,15}",
1457 ) {
1458 let malicious_id = format!("{}{}rm -rf /", base, injection_char);
1459 let encoded = urlencoding::encode(&malicious_id);
1460
1461 prop_assert!(
1463 !encoded.contains(injection_char),
1464 "Injection character '{}' not encoded",
1465 injection_char
1466 );
1467 }
1468 }
1469
1470 proptest! {
1477 #![proptest_config(ProptestConfig {
1478 cases: if cfg!(miri) { 5 } else { 1000 },
1479 failure_persistence: None,
1480 .. ProptestConfig::default()
1481 })]
1482
1483 #[test]
1486 fn proptest_saturating_add_never_overflows(
1487 a in 0u32..=u32::MAX,
1488 b in 0u32..=1000u32,
1489 ) {
1490 let result = a.saturating_add(b);
1491
1492 prop_assert!(result >= a, "Saturating add decreased value");
1494
1495 #[allow(clippy::arithmetic_side_effects)]
1497 {
1498 if a as u64 + b as u64 > u32::MAX as u64 {
1499 prop_assert_eq!(
1500 result,
1501 u32::MAX,
1502 "Expected saturation at MAX for {} + {}",
1503 a, b
1504 );
1505 } else {
1506 prop_assert_eq!(
1507 result,
1508 a + b,
1509 "Expected normal addition for {} + {}",
1510 a, b
1511 );
1512 }
1513 }
1514 }
1515
1516 #[test]
1519 fn proptest_saturating_mul_never_overflows(
1520 a in 0u64..=u64::MAX / 2,
1521 b in 0u64..=100u64,
1522 ) {
1523 let result = a.saturating_mul(b);
1524
1525 if let Some(expected) = a.checked_mul(b) {
1527 prop_assert_eq!(result, expected, "Multiplication mismatch");
1528 } else {
1529 prop_assert_eq!(
1530 result,
1531 u64::MAX,
1532 "Expected saturation at MAX for {} * {}",
1533 a, b
1534 );
1535 }
1536 }
1537
1538 #[test]
1541 fn proptest_counter_increment_safety(
1542 start in 0u32..=u32::MAX - 1000,
1543 increments in 1usize..=100,
1544 ) {
1545 let mut counter = start;
1546
1547 for _ in 0..increments {
1548 let old_value = counter;
1549 counter = counter.saturating_add(1);
1550
1551 prop_assert!(
1553 counter >= old_value,
1554 "Counter decreased from {} to {}",
1555 old_value, counter
1556 );
1557
1558 if old_value == u32::MAX {
1560 prop_assert_eq!(counter, u32::MAX, "Counter should saturate at MAX");
1561 }
1562 }
1563 }
1564
1565 #[test]
1568 fn proptest_page_iteration_overflow_safety(
1569 total_pages in 1u32..=1000u32,
1570 ) {
1571 let mut processed = 0u32;
1573
1574 for page_num in 1..total_pages {
1575 let page_display = page_num.saturating_add(1);
1577
1578 prop_assert!(
1579 page_display >= page_num,
1580 "Page display calculation overflow"
1581 );
1582
1583 processed = processed.saturating_add(1);
1584 }
1585
1586 prop_assert_eq!(
1588 processed,
1589 total_pages.saturating_sub(1),
1590 "Page count mismatch"
1591 );
1592 }
1593 }
1594
1595 proptest! {
1602 #![proptest_config(ProptestConfig {
1603 cases: if cfg!(miri) { 5 } else { 1000 },
1604 failure_persistence: None,
1605 .. ProptestConfig::default()
1606 })]
1607
1608 #[test]
1611 fn proptest_request_builder_handles_arbitrary_input(
1612 start_date in "\\PC{0,256}",
1613 end_date in "\\PC{0,256}",
1614 action in "\\PC{0,100}",
1615 ) {
1616 let request = AuditReportRequest::new(
1618 start_date.clone(),
1619 if end_date.is_empty() { None } else { Some(end_date.clone()) }
1620 );
1621
1622 prop_assert_eq!(&request.start_date, &start_date);
1624
1625 if !end_date.is_empty() {
1626 prop_assert_eq!(&request.end_date, &Some(end_date.clone()));
1627 }
1628
1629 let request = request.with_audit_actions(vec![action.clone()]);
1631 prop_assert!(request.audit_action.is_some());
1632 }
1633
1634 #[test]
1637 fn proptest_request_builder_data_integrity(
1638 start in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1639 actions in prop::collection::vec("[A-Za-z]{5,15}", 0..10),
1640 types in prop::collection::vec("[A-Za-z]{5,15}", 0..10),
1641 user_ids in prop::collection::vec("[0-9]{1,10}", 0..10),
1642 ) {
1643 let request = AuditReportRequest::new(start.clone(), None)
1644 .with_audit_actions(actions.clone())
1645 .with_action_types(types.clone())
1646 .with_target_users(user_ids.clone())
1647 .with_modifier_users(user_ids.clone());
1648
1649 prop_assert_eq!(request.start_date, start);
1651 prop_assert_eq!(request.audit_action, Some(actions));
1652 prop_assert_eq!(request.action_type, Some(types));
1653 prop_assert_eq!(request.target_user_id, Some(user_ids.clone()));
1654 prop_assert_eq!(request.modifier_user_id, Some(user_ids));
1655 }
1656
1657 #[test]
1660 fn proptest_request_builder_empty_collections(
1661 start_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1662 ) {
1663 let request = AuditReportRequest::new(start_date.clone(), None)
1664 .with_audit_actions(vec![])
1665 .with_action_types(vec![])
1666 .with_target_users(vec![])
1667 .with_modifier_users(vec![]);
1668
1669 prop_assert!(request.audit_action.is_some());
1671 prop_assert!(request.action_type.is_some());
1672 prop_assert!(request.target_user_id.is_some());
1673 prop_assert!(request.modifier_user_id.is_some());
1674
1675 if let Some(ref actions) = request.audit_action {
1677 prop_assert_eq!(actions.len(), 0);
1678 }
1679 }
1680
1681 #[test]
1684 fn proptest_request_builder_large_collections(
1685 start_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1686 collection_size in 1usize..=100,
1687 ) {
1688 let large_vec: Vec<String> = (0..collection_size)
1689 .map(|i| format!("item_{}", i))
1690 .collect();
1691
1692 let request = AuditReportRequest::new(start_date, None)
1693 .with_audit_actions(large_vec.clone());
1694
1695 if let Some(ref actions) = request.audit_action {
1696 prop_assert_eq!(
1697 actions.len(),
1698 collection_size,
1699 "Collection size mismatch"
1700 );
1701 }
1702 }
1703 }
1704
1705 proptest! {
1712 #![proptest_config(ProptestConfig {
1713 cases: if cfg!(miri) { 5 } else { 1000 },
1714 failure_persistence: None,
1715 .. ProptestConfig::default()
1716 })]
1717
1718 #[test]
1721 fn proptest_request_serialization_safety(
1722 start_date in "\\PC{0,100}",
1723 actions in prop::collection::vec("\\PC{0,50}", 0..10),
1724 ) {
1725 let request = AuditReportRequest::new(start_date, None)
1726 .with_audit_actions(actions);
1727
1728 let result = serde_json::to_string(&request);
1730 prop_assert!(result.is_ok(), "Serialization failed");
1731
1732 if let Ok(json) = result {
1734 prop_assert!(json.contains("\"report_type\""), "Missing report_type");
1735 prop_assert!(json.contains("\"AUDIT\""), "Wrong report_type value");
1736 }
1737 }
1738
1739 #[test]
1742 fn proptest_json_injection_prevention(
1743 injection in prop::sample::select(vec![
1744 r#"","malicious":"value"#,
1745 "\n\r\t",
1746 "\\",
1747 "\"",
1748 "</script>",
1749 ]),
1750 base_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1751 ) {
1752 let malicious_date = format!("{}{}", base_date, injection);
1753 let request = AuditReportRequest::new(malicious_date, None);
1754
1755 let json = serde_json::to_string(&request)
1756 .expect("Should serialize even with special chars");
1757
1758 let parsed: serde_json::Value = serde_json::from_str(&json)
1760 .expect("Serialized JSON should be parseable");
1761
1762 prop_assert!(parsed.is_object(), "Should be valid JSON object");
1763 }
1764 }
1765
1766 proptest! {
1773 #![proptest_config(ProptestConfig {
1774 cases: if cfg!(miri) { 5 } else { 500 }, failure_persistence: None,
1776 .. ProptestConfig::default()
1777 })]
1778
1779 #[test]
1782 fn proptest_timestamp_error_handling_never_panics(
1783 malformed in prop_oneof![
1784 Just(""),
1786 Just(" "),
1787 Just("\n\t\r"),
1788
1789 Just("2025/01/01 12:00:00"),
1791 Just("01-01-2025 12:00:00"),
1792 Just("2025-01-01T12:00:00Z"),
1793
1794 Just("2025-13-01 12:00:00"), Just("2025-01-32 12:00:00"), Just("2025-01-01 25:00:00"), Just("2025-01-01 12:60:00"), Just("2025-01-01 12:00:60"), Just("2025-01-01"),
1803 Just("2025-01-01 12"),
1804 Just("2025-01-01 12:00"),
1805
1806 Just("2025-01-01; DROP TABLE;"),
1808 Just("../../etc/passwd"),
1809 Just("<script>alert('xss')</script>"),
1810
1811 Just("9999-99-99 99:99:99"),
1813 Just("0000-00-00 00:00:00"),
1814 ],
1815 ) {
1816 let result_commercial = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::Commercial);
1818 let result_european = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::European);
1819 let result_federal = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::Federal);
1820
1821 prop_assert!(result_commercial.is_none() || result_commercial.is_some());
1823 prop_assert!(result_european.is_none() || result_european.is_some());
1824 prop_assert!(result_federal.is_none() || result_federal.is_some());
1825 }
1826
1827 #[test]
1830 fn proptest_hash_handles_all_input_sizes(
1831 size in 0usize..=10_000,
1832 ) {
1833 let input = "x".repeat(size);
1834
1835 let hash = generate_log_hash(&input);
1837
1838 prop_assert_eq!(hash.len(), 32);
1840 prop_assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1841 }
1842
1843 #[test]
1846 fn proptest_hash_handles_binary_data(
1847 null_count in 0usize..=100,
1848 ) {
1849 let input = format!("data{}\0{}\0end", "x".repeat(null_count), "y".repeat(null_count));
1851
1852 let hash = generate_log_hash(&input);
1854
1855 prop_assert_eq!(hash.len(), 32);
1856 prop_assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1857 }
1858 }
1859
1860 #[test]
1865 fn test_url_encoding_sql_injection_attempt() {
1866 let sql_injection = "1' OR '1'='1";
1867 let encoded = urlencoding::encode(sql_injection);
1868
1869 assert!(!encoded.contains('\''));
1871 assert!(!encoded.contains(' ') || encoded.contains('+') || encoded.contains("%20"));
1872 }
1873
1874 #[test]
1875 fn test_url_encoding_path_traversal_variants() {
1876 let variants = vec![
1877 "../../../etc/passwd",
1878 "..%2f..%2f..%2fetc%2fpasswd",
1879 "..\\..\\..\\windows\\system32",
1880 ];
1881
1882 for variant in variants {
1883 let encoded = urlencoding::encode(variant);
1884
1885 assert!(!encoded.contains("../"));
1887 assert!(!encoded.contains("..\\"));
1888 }
1889 }
1890
1891 #[test]
1892 fn test_hash_known_collision_resistance() {
1893 let similar_inputs = [
1895 r#"{"timestamp":"2025-01-01 12:00:00.000"}"#,
1896 r#"{"timestamp":"2025-01-01 12:00:00.001"}"#,
1897 r#"{"timestamp":"2025-01-01 12:00:01.000"}"#,
1898 ];
1899
1900 let hashes: Vec<String> = similar_inputs
1901 .iter()
1902 .map(|input| generate_log_hash(input))
1903 .collect();
1904
1905 for i in 0..hashes.len() {
1907 for j in i + 1..hashes.len() {
1908 if let (Some(hash_i), Some(hash_j)) = (hashes.get(i), hashes.get(j)) {
1909 assert_ne!(
1910 hash_i, hash_j,
1911 "Collision between similar inputs {} and {}",
1912 i, j
1913 );
1914 }
1915 }
1916 }
1917 }
1918
1919 #[test]
1920 fn test_saturating_arithmetic_at_boundaries() {
1921 assert_eq!(u32::MAX.saturating_add(1), u32::MAX);
1923 assert_eq!((u32::MAX - 1).saturating_add(2), u32::MAX);
1924
1925 assert_eq!(u64::MAX.saturating_mul(2), u64::MAX);
1927 assert_eq!((u64::MAX / 2).saturating_mul(3), u64::MAX);
1928 }
1929
1930 #[test]
1931 fn test_timestamp_dst_transitions() {
1932 let result = convert_regional_timestamp_to_utc(
1935 "2025-03-09 02:30:00",
1936 &VeracodeRegion::Commercial,
1937 );
1938 assert!(result.is_some() || result.is_none());
1940
1941 let result = convert_regional_timestamp_to_utc(
1943 "2025-11-02 01:30:00",
1944 &VeracodeRegion::Commercial,
1945 );
1946 assert!(result.is_some() || result.is_none());
1948 }
1949
1950 #[test]
1951 fn test_leap_year_handling() {
1952 let result =
1954 convert_regional_timestamp_to_utc("2024-02-29 12:00:00", &VeracodeRegion::European);
1955 assert!(result.is_some(), "Leap year Feb 29 should be valid");
1956
1957 let result =
1959 convert_regional_timestamp_to_utc("2025-02-29 12:00:00", &VeracodeRegion::European);
1960 assert!(result.is_none(), "Non-leap year Feb 29 should be invalid");
1961 }
1962
1963 #[test]
1964 fn test_empty_request_serialization() {
1965 let request = AuditReportRequest::new("2025-01-01", None);
1966 let json = serde_json::to_string(&request).expect("Should serialize");
1967
1968 assert!(!json.contains("audit_action"));
1970 assert!(!json.contains("action_type"));
1971 assert!(!json.contains("target_user_id"));
1972 assert!(!json.contains("modifier_user_id"));
1973
1974 assert!(json.contains("report_type"));
1976 assert!(json.contains("start_date"));
1977 }
1978 }
1979}