veracode_platform/
reporting.rs

1//! Veracode Reporting API
2//!
3//! This module provides access to the Veracode Reporting REST API for retrieving
4//! audit logs and generating compliance reports.
5use crate::{VeracodeClient, VeracodeError, VeracodeRegion};
6use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
7use chrono_tz::America::New_York;
8use chrono_tz::Europe::Berlin;
9use serde::{Deserialize, Serialize};
10
11/// Request payload for generating an audit report
12#[derive(Debug, Clone, Serialize)]
13pub struct AuditReportRequest {
14    /// The type of report to generate (always "AUDIT" for audit logs)
15    pub report_type: String,
16    /// Start date in YYYY-MM-DD format
17    pub start_date: String,
18    /// Optional end date in YYYY-MM-DD format
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub end_date: Option<String>,
21    /// Optional list of audit actions to filter (e.g., "Delete", "Create", "Update")
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub audit_action: Option<Vec<String>>,
24    /// Optional list of action types to filter (e.g., "Login", "Admin")
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub action_type: Option<Vec<String>>,
27    /// Optional list of target user IDs to filter
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub target_user_id: Option<Vec<String>>,
30    /// Optional list of modifier user IDs to filter
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub modifier_user_id: Option<Vec<String>>,
33}
34
35impl AuditReportRequest {
36    /// Create a new audit report request with just start and end dates
37    #[must_use]
38    pub fn new(start_date: impl Into<String>, end_date: Option<String>) -> Self {
39        Self {
40            report_type: "AUDIT".to_string(),
41            start_date: start_date.into(),
42            end_date,
43            audit_action: None,
44            action_type: None,
45            target_user_id: None,
46            modifier_user_id: None,
47        }
48    }
49
50    /// Add audit action filters
51    #[must_use]
52    pub fn with_audit_actions(mut self, actions: Vec<String>) -> Self {
53        self.audit_action = Some(actions);
54        self
55    }
56
57    /// Add action type filters
58    #[must_use]
59    pub fn with_action_types(mut self, types: Vec<String>) -> Self {
60        self.action_type = Some(types);
61        self
62    }
63
64    /// Add target user ID filters
65    #[must_use]
66    pub fn with_target_users(mut self, user_ids: Vec<String>) -> Self {
67        self.target_user_id = Some(user_ids);
68        self
69    }
70
71    /// Add modifier user ID filters
72    #[must_use]
73    pub fn with_modifier_users(mut self, user_ids: Vec<String>) -> Self {
74        self.modifier_user_id = Some(user_ids);
75        self
76    }
77}
78
79/// Embedded data in generate report response
80#[derive(Debug, Clone, Deserialize)]
81pub struct GenerateReportData {
82    /// The report ID used to retrieve the generated report
83    pub id: String,
84}
85
86/// Response when generating a report
87#[derive(Debug, Clone, Deserialize)]
88pub struct GenerateReportResponse {
89    /// Embedded report data
90    #[serde(rename = "_embedded")]
91    pub embedded: GenerateReportData,
92}
93
94/// Report status values
95#[derive(Debug, Clone, PartialEq, Deserialize)]
96#[serde(rename_all = "UPPERCASE")]
97pub enum ReportStatus {
98    /// Report request has been queued
99    Queued,
100    /// Report request has been submitted
101    Submitted,
102    /// Report is being processed
103    Processing,
104    /// Report has been completed and is ready
105    Completed,
106    /// Report generation failed
107    Failed,
108}
109
110impl std::fmt::Display for ReportStatus {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            ReportStatus::Queued => write!(f, "Queued"),
114            ReportStatus::Submitted => write!(f, "Submitted"),
115            ReportStatus::Processing => write!(f, "Processing"),
116            ReportStatus::Completed => write!(f, "Completed"),
117            ReportStatus::Failed => write!(f, "Failed"),
118        }
119    }
120}
121
122/// Pagination links for navigating report pages
123#[derive(Debug, Clone, Deserialize)]
124pub struct ReportLinks {
125    /// Link to first page
126    pub first: Option<LinkHref>,
127    /// Link to previous page
128    pub prev: Option<LinkHref>,
129    /// Link to current page (self)
130    #[serde(rename = "self")]
131    pub self_link: Option<LinkHref>,
132    /// Link to next page
133    pub next: Option<LinkHref>,
134    /// Link to last page
135    pub last: Option<LinkHref>,
136}
137
138/// A link with href field
139#[derive(Debug, Clone, Deserialize)]
140pub struct LinkHref {
141    /// The URL path for the link
142    pub href: String,
143}
144
145/// Page metadata for pagination
146#[derive(Debug, Clone, Deserialize)]
147pub struct PageMetadata {
148    /// Current page number (0-indexed)
149    pub number: u32,
150    /// Number of items per page
151    pub size: u32,
152    /// Total number of audit log entries across all pages
153    pub total_elements: u32,
154    /// Total number of pages
155    pub total_pages: u32,
156}
157
158/// A single audit log entry (optimized for minimal deserialization)
159///
160/// This struct only deserializes the timestamp field and keeps the rest as raw JSON
161/// to minimize parsing overhead and memory allocations. The hash is computed using
162/// xxHash (much faster than SHA256 for duplicate detection).
163#[derive(Debug, Clone, Serialize)]
164pub struct AuditLogEntry {
165    /// Raw JSON string of the log entry (as received from API)
166    pub raw_log: String,
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the API request fails, the resource is not found,
171    /// or authentication/authorization fails.
172    /// Timestamp converted to UTC (computed from the timestamp field in `raw_log`)
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub timestamp_utc: Option<String>,
175    /// xxHash (128-bit) of the raw log entry for fast duplicate detection
176    pub log_hash: String,
177}
178
179/// Helper struct to extract only the timestamp during deserialization
180#[derive(Debug, Deserialize)]
181struct TimestampExtractor {
182    timestamp: Option<String>,
183}
184
185/// Report data embedded in the response
186#[derive(Debug, Clone, Deserialize)]
187pub struct ReportData {
188    /// Report ID
189    pub id: String,
190    /// Report type (always "AUDIT" for audit reports)
191    pub report_type: String,
192    /// Current status of the report
193    pub status: ReportStatus,
194    /// User who requested the report
195    pub requested_by_user: String,
196    /// Account ID that requested the report
197    pub requested_by_account: u64,
198    /// Date when report was requested
199    pub date_report_requested: String,
200    /// Date when report was completed (null if not completed)
201    pub date_report_completed: Option<String>,
202    /// Date when report expires (null if not completed)
203    pub report_expiration_date: Option<String>,
204    /// Array of audit log entries (raw JSON, processed later for efficiency)
205    pub audit_logs: serde_json::Value,
206    /// Links for pagination (null if not completed)
207    #[serde(rename = "_links")]
208    pub links: Option<ReportLinks>,
209    /// Page metadata (null if not completed)
210    pub page_metadata: Option<PageMetadata>,
211}
212
213/// Full report response with embedded data
214#[derive(Debug, Clone, Deserialize)]
215pub struct ReportResponse {
216    /// Embedded report data
217    #[serde(rename = "_embedded")]
218    pub embedded: ReportData,
219}
220
221/// Convert a timestamp from region-specific timezone to UTC
222///
223/// Each Veracode API region returns timestamps in its corresponding timezone:
224///
225/// # Errors
226///
227/// Returns an error if the API request fails, the resource is not found,
228/// or authentication/authorization fails.
229/// - **Commercial** (api.veracode.com): `America/New_York` (US-East-1)
230///   - EST (Eastern Standard Time): UTC-5 (winter)
231///   - EDT (Eastern Daylight Time): UTC-4 (summer)
232/// - **European** (api.veracode.eu): Europe/Berlin (eu-central-1)
233///   - CET (Central European Time): UTC+1 (winter)
234///   - CEST (Central European Summer Time): UTC+2 (summer)
235///
236/// # Errors
237///
238/// Returns an error if the API request fails, the resource is not found,
239/// or authentication/authorization fails.
240/// - **Federal** (api.veracode.us): `America/New_York` (US-East-1)
241///   - EST/EDT same as Commercial
242///
243/// This function automatically handles Daylight Saving Time (DST) transitions
244/// for each region using the IANA timezone database.
245///
246/// # Arguments
247///
248/// * `timestamp_str` - Timestamp string in format "YYYY-MM-DD HH:MM:SS.sss"
249/// * `region` - The Veracode region determining source timezone
250///
251/// # Returns
252///
253/// UTC timestamp string in same format, or None if parsing fails
254///
255/// # Examples
256///
257/// ```ignore
258/// use veracode_platform::VeracodeRegion;
259///
260/// // European region: Summer timestamp (CEST, UTC+2)
261/// let utc = convert_regional_timestamp_to_utc("2025-06-15 14:30:00.000", &VeracodeRegion::European);
262/// assert_eq!(utc, Some("2025-06-15 12:30:00".to_string())); // 14:30 CEST = 12:30 UTC
263///
264/// // Commercial region: Winter timestamp (EST, UTC-5)
265/// let utc = convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Commercial);
266/// assert_eq!(utc, Some("2025-12-15 19:30:00".to_string())); // 14:30 EST = 19:30 UTC
267/// ```
268fn convert_regional_timestamp_to_utc(
269    timestamp_str: &str,
270    region: &VeracodeRegion,
271) -> Option<String> {
272    // Parse timestamp string - handle variable-length milliseconds
273    let has_millis = timestamp_str.contains('.');
274
275    // Parse the base datetime without milliseconds
276    let naive_dt = if has_millis {
277        // Try to parse with variable-length fractional seconds
278        // The %.f format is flexible and handles 1-9 digits
279        NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.f").ok()?
280    } else {
281        NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S").ok()?
282    };
283
284    // Convert from region-specific timezone to UTC
285    let utc_time = match region {
286        VeracodeRegion::European => {
287            // European region uses Europe/Berlin timezone (CET/CEST)
288            let regional_time: DateTime<_> = Berlin.from_local_datetime(&naive_dt).single()?;
289            regional_time.with_timezone(&Utc)
290        }
291        VeracodeRegion::Commercial | VeracodeRegion::Federal => {
292            // Commercial and Federal regions use America/New_York timezone (EST/EDT)
293            let regional_time: DateTime<_> = New_York.from_local_datetime(&naive_dt).single()?;
294            regional_time.with_timezone(&Utc)
295        }
296    };
297
298    // Format back to string (preserve original millisecond precision)
299    if has_millis {
300        // Format with the same number of decimal places as input
301        let formatted = utc_time.format("%Y-%m-%d %H:%M:%S%.f").to_string();
302        // Ensure we preserve the original precision
303        Some(formatted)
304    } else {
305        Some(utc_time.format("%Y-%m-%d %H:%M:%S").to_string())
306    }
307}
308
309/// Generate a fast hash of a raw log entry JSON string for duplicate detection
310///
311///
312/// # Errors
313///
314/// Returns an error if the API request fails, the resource is not found,
315/// or authentication/authorization fails.
316/// Uses xxHash (`xxh3_128`) which is significantly faster than SHA256 while still
317/// providing excellent collision resistance for deduplication purposes. This is
318/// NOT a cryptographic hash - use only for duplicate detection, not security.
319///
320/// Performance comparison vs SHA256:
321/// - xxHash: ~10-50x faster than SHA256
322/// - Still has excellent collision resistance for duplicate detection
323/// - Returns 32 hex characters (128-bit hash)
324///
325/// # Arguments
326///
327/// * `raw_json` - The raw JSON string of the log entry
328///
329/// # Returns
330///
331/// Hex-encoded xxHash3 (128-bit) hash string (32 characters)
332///
333/// # Examples
334///
335/// ```ignore
336/// let hash = generate_log_hash(r#"{"timestamp":"2025-01-01 12:00:00.000"}"#);
337/// assert_eq!(hash.len(), 32); // xxh3_128 produces 32 hex characters
338/// ```
339fn generate_log_hash(raw_json: &str) -> String {
340    use xxhash_rust::xxh3::xxh3_128;
341
342    // Hash the raw JSON bytes (extremely fast!)
343    let hash = xxh3_128(raw_json.as_bytes());
344
345    // Convert to hex string (128-bit = 32 hex chars)
346    format!("{:032x}", hash)
347}
348
349/// The Reporting API interface
350#[derive(Clone)]
351pub struct ReportingApi {
352    client: VeracodeClient,
353    region: VeracodeRegion,
354}
355
356impl ReportingApi {
357    /// Create a new Reporting API instance
358    #[must_use]
359    pub fn new(client: VeracodeClient) -> Self {
360        let region = client.config().region;
361        Self { client, region }
362    }
363
364    /// Generate an audit report (step 1 of the process)
365    ///
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the API request fails, the resource is not found,
370    /// or authentication/authorization fails.
371    /// This sends a request to generate the report. The API returns a `report_id`
372    /// which can be used to retrieve the report after it's generated.
373    ///
374    /// # Arguments
375    ///
376    /// * `request` - The audit report request parameters
377    ///
378    /// # Returns
379    ///
380    /// The report ID that can be used to retrieve the generated report
381    ///
382    /// # Errors
383    ///
384    /// Returns `VeracodeError` if the request fails
385    pub async fn generate_audit_report(
386        &self,
387        request: &AuditReportRequest,
388    ) -> Result<String, VeracodeError> {
389        let response = self
390            .client
391            .post("/appsec/v1/analytics/report", Some(request))
392            .await?;
393
394        let response_text = response.text().await?;
395        log::debug!("Generate report API response: {}", response_text);
396
397        let generate_response: GenerateReportResponse = serde_json::from_str(&response_text)?;
398        Ok(generate_response.embedded.id)
399    }
400
401    /// Retrieve a generated audit report (step 2 of the process)
402    ///
403    /// This retrieves the report content. The report may still be processing,
404    /// so check the status field in the response.
405    ///
406    /// # Arguments
407    ///
408    /// * `report_id` - The report ID returned from `generate_audit_report`
409    /// * `page` - Optional page number (0-indexed) for pagination
410    ///
411    /// # Returns
412    ///
413    /// The report response with status and audit log data
414    ///
415    /// # Errors
416    ///
417    /// Returns `VeracodeError` if the request fails
418    pub async fn get_audit_report(
419        &self,
420        report_id: &str,
421        page: Option<u32>,
422    ) -> Result<ReportResponse, VeracodeError> {
423        let endpoint = if let Some(page_num) = page {
424            format!("/appsec/v1/analytics/report/{report_id}?page={page_num}")
425        } else {
426            format!("/appsec/v1/analytics/report/{report_id}")
427        };
428
429        let response = self.client.get(&endpoint, None).await?;
430        let response_text = response.text().await?;
431        log::debug!("Get audit report API response: {}", response_text);
432
433        let report_response: ReportResponse = serde_json::from_str(&response_text)?;
434        Ok(report_response)
435    }
436
437    /// Poll for report status until it's completed or failed
438    ///
439    /// This method polls the report status with exponential backoff until
440    /// the report is either completed or failed.
441    ///
442    /// # Arguments
443    ///
444    /// * `report_id` - The report ID to poll
445    /// * `max_attempts` - Maximum number of polling attempts (default: 30)
446    /// * `initial_delay_secs` - Initial delay between polls in seconds (default: 2)
447    ///
448    /// # Returns
449    ///
450    /// The completed report response
451    ///
452    /// # Errors
453    ///
454    /// Returns `VeracodeError` if polling fails or report generation fails
455    pub async fn poll_report_status(
456        &self,
457        report_id: &str,
458        max_attempts: Option<u32>,
459        initial_delay_secs: Option<u64>,
460    ) -> Result<ReportResponse, VeracodeError> {
461        let max_attempts = max_attempts.unwrap_or(30);
462        let initial_delay = initial_delay_secs.unwrap_or(2);
463
464        let mut attempts: u32 = 0;
465        let mut delay_secs = initial_delay;
466
467        loop {
468            attempts = attempts.saturating_add(1);
469
470            // Get current report status
471            let report = self.get_audit_report(report_id, None).await?;
472            let status = &report.embedded.status;
473
474            log::debug!(
475                "Report {} status: {} (attempt {}/{})",
476                report_id,
477                status,
478                attempts,
479                max_attempts
480            );
481
482            match status {
483                ReportStatus::Completed => {
484                    log::info!("Report {} completed successfully", report_id);
485                    return Ok(report);
486                }
487                ReportStatus::Failed => {
488                    return Err(VeracodeError::InvalidResponse(format!(
489                        "Report generation failed for report ID: {}",
490                        report_id
491                    )));
492                }
493                ReportStatus::Queued | ReportStatus::Submitted | ReportStatus::Processing => {
494                    if attempts >= max_attempts {
495                        return Err(VeracodeError::InvalidResponse(format!(
496                            "Report polling timeout after {} attempts. Status: {}",
497                            attempts, status
498                        )));
499                    }
500
501                    log::debug!("Report still processing, waiting {} seconds...", delay_secs);
502                    tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
503
504                    // Exponential backoff with max delay of 30 seconds
505                    delay_secs = std::cmp::min(delay_secs.saturating_mul(2), 30);
506                }
507            }
508        }
509    }
510
511    /// Retrieve all audit logs across all pages (OPTIMIZED)
512    ///
513    /// This method handles pagination automatically and collects all audit log
514    /// entries from all pages into a single vector. It uses optimized processing
515    /// that only deserializes the timestamp field and keeps raw JSON for efficiency.
516    ///
517    /// Performance optimizations:
518    /// - Minimal deserialization: Only extracts timestamp field
519    /// - Zero cloning: Keeps raw JSON strings instead of parsing all fields
520    /// - Fast hashing: Uses xxHash (10-50x faster than SHA256) for duplicate detection
521    ///
522    /// # Arguments
523    ///
524    /// * `report_id` - The report ID (must be in COMPLETED status)
525    ///
526    /// # Returns
527    ///
528    /// A vector containing all audit log entries from all pages
529    ///
530    /// # Errors
531    ///
532    /// Returns `VeracodeError` if any page retrieval fails
533    pub async fn get_all_audit_log_pages(
534        &self,
535        report_id: &str,
536    ) -> Result<Vec<AuditLogEntry>, VeracodeError> {
537        let mut all_logs = Vec::new();
538
539        // Get report status without page parameter first
540        let initial_report = self.get_audit_report(report_id, None).await?;
541
542        // Check if report is completed
543        if initial_report.embedded.status != ReportStatus::Completed {
544            return Err(VeracodeError::InvalidResponse(format!(
545                "Report is not completed. Status: {}",
546                initial_report.embedded.status
547            )));
548        }
549
550        // Check if there are any results
551        let page_metadata = match initial_report.embedded.page_metadata {
552            Some(metadata) if metadata.total_elements > 0 => metadata,
553            Some(metadata) => {
554                // Report has metadata but no elements (total_elements = 0)
555                log::info!(
556                    "Report completed but contains no audit log entries (0 total elements, {} total pages)",
557                    metadata.total_pages
558                );
559                return Ok(all_logs); // Return empty vector
560            }
561            None => {
562                // No metadata at all
563                log::info!("Report completed but contains no audit log entries (no page metadata)");
564                return Ok(all_logs); // Return empty vector
565            }
566        };
567
568        // Collect all pages of raw JSON
569        let mut all_pages_raw = Vec::new();
570
571        // Get first page
572        let first_page = self.get_audit_report(report_id, Some(0)).await?;
573        all_pages_raw.push(first_page.embedded.audit_logs.clone());
574
575        log::info!(
576            "Retrieved page 1/{} ({} total)",
577            page_metadata.total_pages,
578            page_metadata.total_elements
579        );
580
581        // If there are more pages, retrieve them
582        if page_metadata.total_pages > 1 {
583            for page_num in 1..page_metadata.total_pages {
584                log::debug!(
585                    "Retrieving page {}/{}",
586                    page_num.saturating_add(1),
587                    page_metadata.total_pages
588                );
589
590                let page_response = self.get_audit_report(report_id, Some(page_num)).await?;
591                all_pages_raw.push(page_response.embedded.audit_logs.clone());
592
593                log::info!(
594                    "Retrieved page {}/{}",
595                    page_num.saturating_add(1),
596                    page_metadata.total_pages
597                );
598            }
599        }
600
601        // Process all raw log entries efficiently
602        let mut conversion_stats: (u32, u32) = (0, 0); // (successes, failures)
603        let mut total_entries: u32 = 0;
604
605        for page_value in all_pages_raw {
606            if let Some(logs_array) = page_value.as_array() {
607                for log_value in logs_array {
608                    total_entries = total_entries.saturating_add(1);
609
610                    // Get raw JSON string (canonical form for hashing)
611                    let raw_log =
612                        serde_json::to_string(log_value).unwrap_or_else(|_| "{}".to_string());
613
614                    // Generate hash from raw JSON (extremely fast with xxHash!)
615                    let log_hash = generate_log_hash(&raw_log);
616
617                    // Extract only the timestamp field for UTC conversion
618                    let timestamp_utc = if let Ok(extractor) =
619                        serde_json::from_value::<TimestampExtractor>(log_value.clone())
620                    {
621                        if let Some(timestamp) = extractor.timestamp {
622                            match convert_regional_timestamp_to_utc(&timestamp, &self.region) {
623                                Some(utc) => {
624                                    conversion_stats.0 = conversion_stats.0.saturating_add(1);
625                                    Some(utc)
626                                }
627                                None => {
628                                    log::warn!("Failed to convert timestamp to UTC: {}", timestamp);
629                                    conversion_stats.1 = conversion_stats.1.saturating_add(1);
630                                    None
631                                }
632                            }
633                        } else {
634                            None
635                        }
636                    } else {
637                        None
638                    };
639
640                    // Create optimized log entry with minimal allocations
641                    all_logs.push(AuditLogEntry {
642                        raw_log,
643                        timestamp_utc,
644                        log_hash,
645                    });
646                }
647            }
648        }
649
650        log::info!(
651            "Successfully processed {} audit log entries across {} pages",
652            total_entries,
653            page_metadata.total_pages
654        );
655
656        let (region_name, source_timezone) = match self.region {
657            VeracodeRegion::Commercial => (
658                "Commercial (api.veracode.com)",
659                "America/New_York (EST/EDT, UTC-5/-4)",
660            ),
661            VeracodeRegion::European => (
662                "European (api.veracode.eu)",
663                "Europe/Berlin (CET/CEST, UTC+1/+2)",
664            ),
665            VeracodeRegion::Federal => (
666                "Federal (api.veracode.us)",
667                "America/New_York (EST/EDT, UTC-5/-4)",
668            ),
669        };
670
671        log::info!(
672            "Converted {} timestamps from {} to UTC - Region: {} ({} failures)",
673            conversion_stats.0,
674            source_timezone,
675            region_name,
676            conversion_stats.1
677        );
678
679        log::info!(
680            "Generated xxHash hashes for {} log entries (optimized: 10-50x faster than SHA256, zero cloning)",
681            total_entries
682        );
683
684        Ok(all_logs)
685    }
686
687    /// Convenience method to generate and retrieve audit logs in one call
688    ///
689    /// This method combines report generation, status polling, and pagination
690    /// to retrieve all audit logs. It's the recommended way to retrieve audit logs.
691    ///
692    /// # Arguments
693    ///
694    /// * `request` - The audit report request parameters
695    ///
696    /// # Returns
697    ///
698    /// The audit log data as a JSON value containing all entries from all pages
699    ///
700    /// # Errors
701    ///
702    /// Returns `VeracodeError` if the request fails
703    pub async fn get_audit_logs(
704        &self,
705        request: &AuditReportRequest,
706    ) -> Result<serde_json::Value, VeracodeError> {
707        // Step 1: Generate the report
708        log::info!(
709            "Generating audit report for date range: {} to {}",
710            request.start_date,
711            request.end_date.as_deref().unwrap_or("now")
712        );
713        let report_id = self.generate_audit_report(request).await?;
714        log::info!("Report generated with ID: {}", report_id);
715
716        // Step 2: Poll for report completion
717        log::info!("Polling for report completion...");
718        let completed_report = self.poll_report_status(&report_id, None, None).await?;
719        log::info!(
720            "Report completed at: {}",
721            completed_report
722                .embedded
723                .date_report_completed
724                .as_deref()
725                .unwrap_or("unknown")
726        );
727
728        // Step 3: Retrieve all pages
729        log::info!("Retrieving all audit log pages...");
730        let mut all_logs = self.get_all_audit_log_pages(&report_id).await?;
731
732        // Step 4: Sort logs by timestamp_utc (oldest first, newest last)
733        log::info!(
734            "Sorting {} audit logs by timestamp (oldest to newest)...",
735            all_logs.len()
736        );
737        all_logs.sort_by(|a, b| {
738            match (&a.timestamp_utc, &b.timestamp_utc) {
739                // Both have timestamps - parse and compare them
740                (Some(ts_a), Some(ts_b)) => {
741                    // Parse timestamps for comparison
742                    // Format is "YYYY-MM-DD HH:MM:SS" (possibly with milliseconds)
743                    let parsed_a = NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S%.f")
744                        .or_else(|_| NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S"));
745                    let parsed_b = NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S%.f")
746                        .or_else(|_| NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S"));
747
748                    match (parsed_a, parsed_b) {
749                        (Ok(dt_a), Ok(dt_b)) => dt_a.cmp(&dt_b), // Both parsed successfully
750                        (Ok(_), Err(_)) => std::cmp::Ordering::Less, // a is valid, b is not - a comes first
751                        (Err(_), Ok(_)) => std::cmp::Ordering::Greater, // b is valid, a is not - b comes first
752                        (Err(_), Err(_)) => std::cmp::Ordering::Equal, // Neither parsed - keep original order
753                    }
754                }
755                // Only a has timestamp - a comes first
756                (Some(_), None) => std::cmp::Ordering::Less,
757                // Only b has timestamp - b comes first
758                (None, Some(_)) => std::cmp::Ordering::Greater,
759                // Neither has timestamp - keep original order
760                (None, None) => std::cmp::Ordering::Equal,
761            }
762        });
763        log::info!("Logs sorted successfully (oldest to newest)");
764
765        // Convert to JSON for backward compatibility with veraaudit
766        let json_logs = serde_json::to_value(&all_logs)?;
767        log::info!(
768            "Successfully retrieved {} total audit log entries",
769            all_logs.len()
770        );
771
772        Ok(json_logs)
773    }
774}
775
776/// Error type for reporting operations
777#[derive(Debug, thiserror::Error)]
778#[must_use = "Need to handle all error enum types."]
779pub enum ReportingError {
780    /// Wraps a Veracode API error
781    #[error("Veracode API error: {0}")]
782    VeracodeApi(#[from] VeracodeError),
783
784    /// Invalid date format
785    #[error("Invalid date format: {0}")]
786    InvalidDate(String),
787
788    /// Date range exceeds maximum allowed (6 months)
789    #[error("Date range exceeds maximum allowed: {0}")]
790    DateRangeExceeded(String),
791}
792
793#[cfg(test)]
794#[allow(clippy::expect_used)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn test_audit_report_request_new() {
800        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
801
802        assert_eq!(request.report_type, "AUDIT");
803        assert_eq!(request.start_date, "2025-01-01");
804        assert_eq!(request.end_date, Some("2025-01-31".to_string()));
805        assert!(request.audit_action.is_none());
806        assert!(request.action_type.is_none());
807    }
808
809    #[test]
810    fn test_audit_report_request_with_filters() {
811        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()))
812            .with_audit_actions(vec!["Delete".to_string(), "Create".to_string()])
813            .with_action_types(vec!["Admin".to_string()]);
814
815        assert_eq!(
816            request.audit_action,
817            Some(vec!["Delete".to_string(), "Create".to_string()])
818        );
819        assert_eq!(request.action_type, Some(vec!["Admin".to_string()]));
820    }
821
822    #[test]
823    fn test_audit_report_request_serialization() {
824        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
825        let json = serde_json::to_string(&request).expect("should serialize to json");
826
827        assert!(json.contains("\"report_type\":\"AUDIT\""));
828        assert!(json.contains("\"start_date\":\"2025-01-01\""));
829        assert!(json.contains("\"end_date\":\"2025-01-31\""));
830    }
831
832    #[test]
833    fn test_audit_report_request_serialization_without_optional_fields() {
834        let request = AuditReportRequest::new("2025-01-01", None);
835        let json = serde_json::to_string(&request).expect("should serialize to json");
836
837        // Optional fields should not be present when None
838        assert!(!json.contains("end_date"));
839        assert!(!json.contains("audit_action"));
840        assert!(!json.contains("action_type"));
841    }
842
843    #[test]
844    fn test_convert_european_timezone_winter() {
845        // Winter timestamp: CET is UTC+1
846        let result =
847            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.000", &VeracodeRegion::European);
848        assert!(result.is_some());
849        // 10:00 CET = 09:00 UTC
850        // Note: %.f format drops trailing zeros
851        assert_eq!(
852            result.expect("should convert timestamp"),
853            "2025-01-15 09:00:00"
854        );
855    }
856
857    #[test]
858    fn test_convert_european_timezone_summer() {
859        // Summer timestamp: CEST is UTC+2
860        let result =
861            convert_regional_timestamp_to_utc("2025-06-15 10:00:00.000", &VeracodeRegion::European);
862        assert!(result.is_some());
863        // 10:00 CEST = 08:00 UTC
864        assert_eq!(
865            result.expect("should convert timestamp"),
866            "2025-06-15 08:00:00"
867        );
868    }
869
870    #[test]
871    fn test_convert_commercial_timezone_winter() {
872        // Winter timestamp: EST is UTC-5
873        let result = convert_regional_timestamp_to_utc(
874            "2025-01-15 14:30:00.000",
875            &VeracodeRegion::Commercial,
876        );
877        assert!(result.is_some());
878        // 14:30 EST = 19:30 UTC
879        assert_eq!(
880            result.expect("should convert timestamp"),
881            "2025-01-15 19:30:00"
882        );
883    }
884
885    #[test]
886    fn test_convert_commercial_timezone_summer() {
887        // Summer timestamp: EDT is UTC-4
888        let result = convert_regional_timestamp_to_utc(
889            "2025-06-15 14:30:00.000",
890            &VeracodeRegion::Commercial,
891        );
892        assert!(result.is_some());
893        // 14:30 EDT = 18:30 UTC
894        assert_eq!(
895            result.expect("should convert timestamp"),
896            "2025-06-15 18:30:00"
897        );
898    }
899
900    #[test]
901    fn test_convert_federal_timezone_winter() {
902        // Federal uses same timezone as Commercial (America/New_York)
903        let result =
904            convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Federal);
905        assert!(result.is_some());
906        // 14:30 EST = 19:30 UTC
907        assert_eq!(
908            result.expect("should convert timestamp"),
909            "2025-12-15 19:30:00"
910        );
911    }
912
913    #[test]
914    fn test_convert_timezone_without_milliseconds() {
915        // Test without milliseconds
916        let result =
917            convert_regional_timestamp_to_utc("2025-01-15 10:00:00", &VeracodeRegion::European);
918        assert!(result.is_some());
919        // Should not have milliseconds in output
920        assert_eq!(
921            result.expect("should convert timestamp"),
922            "2025-01-15 09:00:00"
923        );
924    }
925
926    #[test]
927    fn test_convert_timezone_variable_milliseconds() {
928        // Test with different millisecond precisions
929        let result =
930            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.1", &VeracodeRegion::European);
931        assert!(result.is_some());
932
933        let result =
934            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.12", &VeracodeRegion::European);
935        assert!(result.is_some());
936
937        let result = convert_regional_timestamp_to_utc(
938            "2025-01-15 10:00:00.123456",
939            &VeracodeRegion::European,
940        );
941        assert!(result.is_some());
942    }
943
944    #[test]
945    fn test_convert_timezone_invalid_format() {
946        // Invalid format should return None
947        let result = convert_regional_timestamp_to_utc("invalid", &VeracodeRegion::European);
948        assert!(result.is_none());
949
950        let result =
951            convert_regional_timestamp_to_utc("2025-13-45 25:99:99", &VeracodeRegion::Commercial);
952        assert!(result.is_none());
953    }
954}