1use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
7use crate::{VeracodeClient, VeracodeError};
8use log::{debug, error, warn};
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CweInfo {
15 pub id: u32,
17 pub name: String,
19 pub href: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct FindingCategory {
26 pub id: u32,
28 pub name: String,
30 pub href: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct FindingStatus {
37 pub first_found_date: String,
39 pub status: String,
41 pub resolution: String,
43 pub mitigation_review_status: String,
45 pub new: bool,
47 pub resolution_status: String,
49 pub last_seen_date: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct FindingDetails {
56 pub severity: u32,
58 pub cwe: CweInfo,
60 pub file_path: String,
62 pub file_name: String,
64 pub module: String,
66 pub relative_location: i32,
68 pub finding_category: FindingCategory,
70 pub procedure: String,
72 pub exploitability: i32,
74 pub attack_vector: String,
76 pub file_line_number: u32,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct RestFinding {
83 pub issue_id: u32,
85 pub scan_type: String,
87 pub description: String,
89 pub count: u32,
91 pub context_type: String,
93 pub context_guid: String,
95 pub violates_policy: bool,
97 pub finding_status: FindingStatus,
99 pub finding_details: FindingDetails,
101 pub build_id: u64,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FindingsEmbedded {
108 pub findings: Vec<RestFinding>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HalLink {
115 pub href: String,
117 #[serde(default)]
119 pub templated: Option<bool>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FindingsLinks {
125 pub first: Option<HalLink>,
127 #[serde(rename = "self")]
129 pub self_link: HalLink,
130 pub next: Option<HalLink>,
132 pub last: Option<HalLink>,
134 pub application: HalLink,
136 pub sca: Option<HalLink>,
138 pub sandbox: Option<HalLink>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PageInfo {
145 pub size: u32,
147 pub total_elements: u32,
149 pub total_pages: u32,
151 pub number: u32,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct FindingsResponse {
158 #[serde(rename = "_embedded")]
160 pub embedded: FindingsEmbedded,
161 #[serde(rename = "_links")]
163 pub links: FindingsLinks,
164 pub page: PageInfo,
166}
167
168impl FindingsResponse {
169 #[must_use]
171 pub fn findings(&self) -> &[RestFinding] {
172 &self.embedded.findings
173 }
174
175 #[must_use]
177 pub fn has_next_page(&self) -> bool {
178 self.links.next.is_some()
179 }
180
181 #[must_use]
183 pub fn current_page(&self) -> u32 {
184 self.page.number
185 }
186
187 #[must_use]
189 pub fn total_pages(&self) -> u32 {
190 self.page.total_pages
191 }
192
193 #[must_use]
195 pub fn is_last_page(&self) -> bool {
196 self.page.number.saturating_add(1) >= self.page.total_pages
197 }
198
199 #[must_use]
201 pub fn total_elements(&self) -> u32 {
202 self.page.total_elements
203 }
204}
205
206#[derive(Debug, Clone)]
208pub struct FindingsQuery<'a> {
209 pub app_guid: Cow<'a, str>,
211 pub context: Option<Cow<'a, str>>,
213 pub page: Option<u32>,
215 pub size: Option<u32>,
217 pub severity: Option<Vec<u32>>,
219 pub cwe_id: Option<Vec<String>>,
221 pub scan_type: Option<Cow<'a, str>>,
223 pub violates_policy: Option<bool>,
225}
226
227impl<'a> FindingsQuery<'a> {
228 #[must_use]
230 pub fn new(app_guid: &'a str) -> Self {
231 Self {
232 app_guid: Cow::Borrowed(app_guid),
233 context: None,
234 page: None,
235 size: None,
236 severity: None,
237 cwe_id: None,
238 scan_type: None,
239 violates_policy: None,
240 }
241 }
242
243 #[must_use]
245 pub fn for_sandbox(app_guid: &'a str, sandbox_guid: &'a str) -> Self {
246 Self {
247 app_guid: Cow::Borrowed(app_guid),
248 context: Some(Cow::Borrowed(sandbox_guid)),
249 page: None,
250 size: None,
251 severity: None,
252 cwe_id: None,
253 scan_type: None,
254 violates_policy: None,
255 }
256 }
257
258 #[must_use]
260 pub fn with_sandbox(mut self, sandbox_guid: &'a str) -> Self {
261 self.context = Some(Cow::Borrowed(sandbox_guid));
262 self
263 }
264
265 #[must_use]
267 pub fn with_pagination(mut self, page: u32, size: u32) -> Self {
268 self.page = Some(page);
269 self.size = Some(size);
270 self
271 }
272
273 #[must_use]
275 pub fn with_severity(mut self, severity: Vec<u32>) -> Self {
276 self.severity = Some(severity);
277 self
278 }
279
280 #[must_use]
282 pub fn with_cwe(mut self, cwe_ids: Vec<String>) -> Self {
283 self.cwe_id = Some(cwe_ids);
284 self
285 }
286
287 #[must_use]
289 pub fn with_scan_type(mut self, scan_type: &'a str) -> Self {
290 self.scan_type = Some(Cow::Borrowed(scan_type));
291 self
292 }
293
294 #[must_use]
296 pub fn policy_violations_only(mut self) -> Self {
297 self.violates_policy = Some(true);
298 self
299 }
300}
301
302#[derive(Debug, thiserror::Error)]
304#[must_use = "Need to handle all error enum types."]
305pub enum FindingsError {
306 #[error("Application not found: {app_guid}")]
308 ApplicationNotFound { app_guid: String },
309
310 #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
312 SandboxNotFound {
313 app_guid: String,
314 sandbox_guid: String,
315 },
316
317 #[error("Invalid pagination parameters: page={page}, size={size}")]
319 InvalidPagination { page: u32, size: u32 },
320
321 #[error("No findings available for the specified context")]
323 NoFindings,
324
325 #[error("Findings API request failed: {source}")]
327 RequestFailed {
328 #[from]
329 source: VeracodeError,
330 },
331}
332
333#[derive(Clone)]
335pub struct FindingsApi {
336 client: VeracodeClient,
337}
338
339impl FindingsApi {
340 #[must_use]
342 pub fn new(client: VeracodeClient) -> Self {
343 Self { client }
344 }
345
346 pub async fn get_findings(
353 &self,
354 query: &FindingsQuery<'_>,
355 ) -> Result<FindingsResponse, FindingsError> {
356 debug!("Getting findings for app: {}", query.app_guid);
357
358 let endpoint = format!("/appsec/v2/applications/{}/findings", query.app_guid);
359 let mut params = Vec::new();
360
361 if let Some(context) = &query.context {
363 params.push(("context".to_string(), context.to_string()));
364 debug!("Using sandbox context: {context}");
365 }
366
367 if let Some(page) = query.page {
369 params.push(("page".to_string(), page.to_string()));
370 }
371
372 if let Some(size) = query.size {
373 params.push(("size".to_string(), size.to_string()));
374 }
375
376 if let Some(severity) = &query.severity {
378 for sev in severity {
379 params.push(("severity".to_string(), sev.to_string()));
380 }
381 }
382
383 if let Some(cwe_ids) = &query.cwe_id {
384 for cwe in cwe_ids {
385 params.push(("cwe".to_string(), cwe.clone()));
386 }
387 }
388
389 if let Some(scan_type) = &query.scan_type {
390 params.push(("scan_type".to_string(), scan_type.to_string()));
391 }
392
393 if let Some(violates_policy) = query.violates_policy {
394 params.push(("violates_policy".to_string(), violates_policy.to_string()));
395 }
396
397 debug!(
398 "Calling findings endpoint: {} with {} parameters",
399 endpoint,
400 params.len()
401 );
402
403 let params_ref: Vec<(&str, &str)> = params
405 .iter()
406 .map(|(k, v)| (k.as_str(), v.as_str()))
407 .collect();
408
409 let response = self
410 .client
411 .get_with_query_params(&endpoint, ¶ms_ref)
412 .await
413 .map_err(|e| match (&e, &query.context) {
414 (VeracodeError::NotFound { .. }, Some(context)) => FindingsError::SandboxNotFound {
415 app_guid: query.app_guid.to_string(),
416 sandbox_guid: context.to_string(),
417 },
418 (VeracodeError::NotFound { .. }, None) => FindingsError::ApplicationNotFound {
419 app_guid: query.app_guid.to_string(),
420 },
421 (
422 VeracodeError::Http(_)
423 | VeracodeError::Serialization(_)
424 | VeracodeError::Authentication(_)
425 | VeracodeError::InvalidResponse(_)
426 | VeracodeError::HttpStatus { .. }
427 | VeracodeError::InvalidConfig(_)
428 | VeracodeError::RetryExhausted(_)
429 | VeracodeError::RateLimited { .. }
430 | VeracodeError::Validation(_),
431 _,
432 ) => FindingsError::RequestFailed { source: e },
433 })?;
434
435 let response_text = response
437 .text()
438 .await
439 .map_err(|e| FindingsError::RequestFailed {
440 source: VeracodeError::Http(e),
441 })?;
442
443 let char_count = response_text.chars().count();
444 if char_count > 500 {
445 let truncated: String = response_text.chars().take(500).collect();
446 debug!(
447 "Raw API response (first 500 chars): {}... [truncated {} more characters]",
448 truncated,
449 char_count.saturating_sub(500)
450 );
451 } else {
452 debug!("Raw API response: {response_text}");
453 }
454
455 validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
457 error!("JSON validation failed: {e}");
458 FindingsError::RequestFailed {
459 source: VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e)),
460 }
461 })?;
462
463 let findings_response: FindingsResponse =
464 serde_json::from_str(&response_text).map_err(|e| {
465 error!("JSON parsing error: {e}");
466 debug!("Full response that failed to parse: {response_text}");
467 FindingsError::RequestFailed {
468 source: VeracodeError::Serialization(e),
469 }
470 })?;
471
472 debug!(
473 "Retrieved {} findings on page {}/{}",
474 findings_response.findings().len(),
475 findings_response.current_page().saturating_add(1),
476 findings_response.total_pages()
477 );
478
479 Ok(findings_response)
480 }
481
482 pub async fn get_all_findings(
489 &self,
490 query: &FindingsQuery<'_>,
491 ) -> Result<Vec<RestFinding>, FindingsError> {
492 debug!("Getting all findings for app: {}", query.app_guid);
493
494 let mut all_findings = Vec::new();
495 let mut current_page = 0;
496 let page_size = 500; loop {
499 let mut page_query = query.clone();
500 page_query.page = Some(current_page);
501 page_query.size = Some(page_size);
502
503 let response = self.get_findings(&page_query).await?;
504
505 if response.findings().is_empty() {
506 debug!("No more findings found on page {current_page}");
507 break;
508 }
509
510 let page_findings = response.findings().len();
511 all_findings.extend_from_slice(response.findings());
512
513 debug!(
514 "Added {} findings from page {}, total so far: {}",
515 page_findings,
516 current_page,
517 all_findings.len()
518 );
519
520 if response.is_last_page() {
522 debug!("Reached last page ({current_page}), stopping");
523 break;
524 }
525
526 current_page = current_page.saturating_add(1);
527
528 if current_page > 1000 {
530 warn!(
531 "Reached maximum page limit (1000) while fetching findings for app: {}",
532 query.app_guid
533 );
534 break;
535 }
536 }
537
538 debug!(
539 "Retrieved total of {} findings across {} pages",
540 all_findings.len(),
541 current_page.saturating_add(1)
542 );
543 Ok(all_findings)
544 }
545
546 pub async fn get_policy_findings(
553 &self,
554 app_guid: &str,
555 ) -> Result<FindingsResponse, FindingsError> {
556 self.get_findings(&FindingsQuery::new(app_guid)).await
557 }
558
559 pub async fn get_sandbox_findings(
566 &self,
567 app_guid: &str,
568 sandbox_guid: &str,
569 ) -> Result<FindingsResponse, FindingsError> {
570 self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
571 .await
572 }
573
574 pub async fn get_all_policy_findings(
581 &self,
582 app_guid: &str,
583 ) -> Result<Vec<RestFinding>, FindingsError> {
584 self.get_all_findings(&FindingsQuery::new(app_guid)).await
585 }
586
587 pub async fn get_all_sandbox_findings(
594 &self,
595 app_guid: &str,
596 sandbox_guid: &str,
597 ) -> Result<Vec<RestFinding>, FindingsError> {
598 self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
599 .await
600 }
601}
602
603#[cfg(test)]
604#[allow(clippy::expect_used)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_findings_query_builder() {
610 let query = FindingsQuery::new("app-123")
611 .with_pagination(0, 50)
612 .with_severity(vec![3, 4, 5])
613 .policy_violations_only();
614
615 assert_eq!(query.app_guid, "app-123");
616 assert_eq!(query.page, Some(0));
617 assert_eq!(query.size, Some(50));
618 assert_eq!(query.severity, Some(vec![3, 4, 5]));
619 assert_eq!(query.violates_policy, Some(true));
620 assert!(query.context.is_none());
621 }
622
623 #[test]
624 fn test_sandbox_query_builder() {
625 let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
626
627 assert_eq!(query.app_guid, "app-123");
628 assert_eq!(
629 query.context.as_ref().expect("should have context"),
630 "sandbox-456"
631 );
632 assert_eq!(query.page, Some(1));
633 assert_eq!(query.size, Some(100));
634 }
635
636 #[test]
637 fn test_findings_response_helpers() {
638 let response = FindingsResponse {
639 embedded: FindingsEmbedded {
640 findings: vec![], },
642 links: FindingsLinks {
643 first: Some(HalLink {
644 href: "first".to_string(),
645 templated: None,
646 }),
647 self_link: HalLink {
648 href: "self".to_string(),
649 templated: None,
650 },
651 next: Some(HalLink {
652 href: "next".to_string(),
653 templated: None,
654 }),
655 last: Some(HalLink {
656 href: "last".to_string(),
657 templated: None,
658 }),
659 application: HalLink {
660 href: "app".to_string(),
661 templated: None,
662 },
663 sca: None,
664 sandbox: None,
665 },
666 page: PageInfo {
667 size: 20,
668 total_elements: 100,
669 total_pages: 5,
670 number: 2,
671 },
672 };
673
674 assert_eq!(response.current_page(), 2);
675 assert_eq!(response.total_pages(), 5);
676 assert_eq!(response.total_elements(), 100);
677 assert!(response.has_next_page());
678 assert!(!response.is_last_page());
679 }
680}
681
682#[cfg(test)]
687#[allow(clippy::expect_used)]
688mod proptest_security {
689 use super::*;
690 use proptest::prelude::*;
691
692 proptest! {
697 #![proptest_config(ProptestConfig {
698 cases: if cfg!(miri) { 5 } else { 1000 },
699 failure_persistence: None,
700 .. ProptestConfig::default()
701 })]
702
703 #[test]
710 fn prop_is_last_page_handles_overflow(
711 current_page in any::<u32>(),
712 total_pages in any::<u32>()
713 ) {
714 let response = create_test_response(current_page, total_pages);
715
716 let is_last = response.is_last_page();
718
719 if current_page == u32::MAX {
721 assert!(is_last);
724 } else if total_pages == 0 {
725 assert!(is_last);
727 } else {
728 let expected = current_page.saturating_add(1) >= total_pages;
730 assert_eq!(is_last, expected);
731 }
732 }
733
734 #[test]
738 fn prop_has_next_page_consistency(
739 current_page in 0u32..1000u32,
740 total_pages in 1u32..1001u32
741 ) {
742 let mut response = create_test_response(current_page, total_pages);
743
744 if response.is_last_page() {
746 response.links.next = None;
747 }
748
749 let has_next = response.has_next_page();
750 let is_last = response.is_last_page();
751
752 if response.links.next.is_some() {
754 assert!(has_next);
755 } else {
756 assert!(!has_next);
757 }
758
759 if !is_last && response.links.next.is_some() {
761 assert!(has_next);
762 }
763
764 response.links.next = None;
766 assert!(!response.has_next_page());
767 }
768
769 #[test]
773 fn prop_page_accessors_never_panic(
774 current in any::<u32>(),
775 total in any::<u32>(),
776 elements in any::<u32>()
777 ) {
778 let mut response = create_test_response(current, total);
779 response.page.total_elements = elements;
780
781 let _ = response.current_page();
783 let _ = response.total_pages();
784 let _ = response.total_elements();
785 let _ = response.is_last_page();
786 let _ = response.has_next_page();
787 }
788 }
789
790 proptest! {
795 #![proptest_config(ProptestConfig {
796 cases: if cfg!(miri) { 5 } else { 1000 },
797 failure_persistence: None,
798 .. ProptestConfig::default()
799 })]
800
801 #[test]
806 fn prop_query_builder_handles_malicious_strings(
807 app_guid in "\\PC*",
808 sandbox_guid in "\\PC*",
809 scan_type in "\\PC*"
810 ) {
811 let query = FindingsQuery::new(&app_guid);
813 assert_eq!(query.app_guid.as_ref(), &app_guid);
814
815 let query = FindingsQuery::for_sandbox(&app_guid, &sandbox_guid);
816 assert_eq!(query.app_guid.as_ref(), &app_guid);
817 assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
818
819 let query = query.with_scan_type(&scan_type);
820 assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
821 }
822
823 #[test]
827 fn prop_severity_filter_accepts_any_u32(
828 severity_values in prop::collection::vec(any::<u32>(), 0..10)
829 ) {
830 let query = FindingsQuery::new("test-app")
831 .with_severity(severity_values.clone());
832
833 assert_eq!(query.severity, Some(severity_values));
835 }
836
837 #[test]
841 fn prop_cwe_filter_handles_arbitrary_strings(
842 cwe_ids in prop::collection::vec("\\PC*", 0..20)
843 ) {
844 let query = FindingsQuery::new("test-app")
845 .with_cwe(cwe_ids.clone());
846
847 assert_eq!(query.cwe_id, Some(cwe_ids));
848 }
849
850 #[test]
854 fn prop_pagination_parameters_safe(
855 page in any::<u32>(),
856 size in any::<u32>()
857 ) {
858 let query = FindingsQuery::new("test-app")
859 .with_pagination(page, size);
860
861 assert_eq!(query.page, Some(page));
862 assert_eq!(query.size, Some(size));
863
864 if let (Some(p), Some(s)) = (query.page, query.size) {
866 assert_eq!(p, page);
867 assert_eq!(s, size);
868 }
869 }
870 }
871
872 proptest! {
877 #![proptest_config(ProptestConfig {
878 cases: if cfg!(miri) { 5 } else { 1000 },
879 failure_persistence: None,
880 .. ProptestConfig::default()
881 })]
882
883 #[test]
887 fn prop_string_truncation_utf8_safe(
888 text in "\\PC{0,2000}"
889 ) {
890 let char_count = text.chars().count();
891
892 if char_count > 500 {
894 let truncated: String = text.chars().take(500).collect();
895 let remaining = char_count.saturating_sub(500);
896
897 assert!(truncated.is_ascii() || std::str::from_utf8(truncated.as_bytes()).is_ok());
900
901 assert!(truncated.chars().count() <= 500);
903
904 assert_eq!(remaining, char_count.saturating_sub(500));
906
907 assert!(remaining <= char_count);
909 }
910 }
911
912 #[test]
916 #[allow(clippy::arithmetic_side_effects)] fn prop_saturating_sub_prevents_underflow(
918 a in any::<u32>(),
919 b in any::<u32>()
920 ) {
921 let result = a.saturating_sub(b);
922
923 assert!(result <= a);
925
926 if a >= b {
928 assert_eq!(result, a - b);
929 } else {
930 assert_eq!(result, 0);
932 }
933 }
934 }
935
936 proptest! {
941 #![proptest_config(ProptestConfig {
942 cases: if cfg!(miri) { 5 } else { 500 },
943 failure_persistence: None,
944 .. ProptestConfig::default()
945 })]
946
947 #[test]
951 #[allow(clippy::cast_possible_truncation)] fn prop_query_params_vector_safe(
953 severity_count in 0usize..100,
954 cwe_count in 0usize..100
955 ) {
956 let query = FindingsQuery::new("test-app")
957 .with_severity((0..severity_count).map(|i| i as u32).collect())
958 .with_cwe((0..cwe_count).map(|i| format!("CWE-{}", i)).collect());
959
960 if let Some(ref severity) = query.severity {
962 assert_eq!(severity.len(), severity_count);
963 }
964
965 if let Some(ref cwe_ids) = query.cwe_id {
966 assert_eq!(cwe_ids.len(), cwe_count);
967 }
968 }
969
970 #[test]
974 #[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn prop_findings_list_memory_safe(
977 finding_count in 0usize..1000
978 ) {
979 let findings: Vec<RestFinding> = (0..finding_count)
980 .map(|i| create_test_finding(i as u32))
981 .collect();
982
983 let response = FindingsResponse {
984 embedded: FindingsEmbedded { findings },
985 links: create_test_links(),
986 page: PageInfo {
987 size: 100,
988 total_elements: finding_count as u32,
989 total_pages: (finding_count / 100) as u32 + 1,
990 number: 0,
991 },
992 };
993
994 let findings_slice = response.findings();
996 assert_eq!(findings_slice.len(), finding_count);
997 }
998 }
999
1000 proptest! {
1005 #![proptest_config(ProptestConfig {
1006 cases: if cfg!(miri) { 5 } else { 500 },
1007 failure_persistence: None,
1008 .. ProptestConfig::default()
1009 })]
1010
1011 #[test]
1015 fn prop_error_display_safe(
1016 app_guid in "[a-zA-Z0-9\\-]{1,100}",
1017 sandbox_guid in "[a-zA-Z0-9\\-]{1,100}",
1018 page in any::<u32>(),
1019 size in any::<u32>()
1020 ) {
1021 let err1 = FindingsError::ApplicationNotFound {
1023 app_guid: app_guid.clone()
1024 };
1025 let msg1 = format!("{}", err1);
1026 assert!(msg1.contains(&app_guid));
1027
1028 let err2 = FindingsError::SandboxNotFound {
1029 app_guid: app_guid.clone(),
1030 sandbox_guid: sandbox_guid.clone(),
1031 };
1032 let msg2 = format!("{}", err2);
1033 assert!(msg2.contains(&app_guid));
1034 assert!(msg2.contains(&sandbox_guid));
1035
1036 let err3 = FindingsError::InvalidPagination { page, size };
1037 let msg3 = format!("{}", err3);
1038 assert!(msg3.contains(&page.to_string()));
1039 assert!(msg3.contains(&size.to_string()));
1040
1041 let err4 = FindingsError::NoFindings;
1042 let msg4 = format!("{}", err4);
1043 assert!(!msg4.is_empty());
1044 }
1045 }
1046
1047 proptest! {
1052 #![proptest_config(ProptestConfig {
1053 cases: if cfg!(miri) { 5 } else { 500 },
1054 failure_persistence: None,
1055 .. ProptestConfig::default()
1056 })]
1057
1058 #[test]
1062 fn prop_builder_chain_preserves_data(
1063 app_guid in "[a-zA-Z0-9\\-]{1,50}",
1064 sandbox_guid in "[a-zA-Z0-9\\-]{1,50}",
1065 page in 0u32..1000,
1066 size in 1u32..1000,
1067 scan_type in "[A-Z]{1,20}"
1068 ) {
1069 let query = FindingsQuery::new(&app_guid)
1070 .with_sandbox(&sandbox_guid)
1071 .with_pagination(page, size)
1072 .with_scan_type(&scan_type)
1073 .policy_violations_only();
1074
1075 assert_eq!(query.app_guid.as_ref(), &app_guid);
1077 assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
1078 assert_eq!(query.page, Some(page));
1079 assert_eq!(query.size, Some(size));
1080 assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
1081 assert_eq!(query.violates_policy, Some(true));
1082 }
1083
1084 #[test]
1088 fn prop_query_clone_independence(
1089 app_guid in "[a-zA-Z0-9\\-]{1,50}"
1090 ) {
1091 let query1 = FindingsQuery::new(&app_guid)
1092 .with_pagination(0, 100);
1093
1094 let query2 = query1.clone();
1095
1096 assert_eq!(query1.app_guid, query2.app_guid);
1098 assert_eq!(query1.page, query2.page);
1099
1100 let query3 = query2.with_pagination(1, 200);
1102 assert_eq!(query3.page, Some(1));
1103 }
1105 }
1106
1107 proptest! {
1112 #![proptest_config(ProptestConfig {
1113 cases: if cfg!(miri) { 5 } else { 500 },
1114 failure_persistence: None,
1115 .. ProptestConfig::default()
1116 })]
1117
1118 #[test]
1122 fn prop_serde_roundtrip_safe(
1123 issue_id in any::<u32>(),
1124 count in any::<u32>(),
1125 build_id in any::<u64>(),
1126 severity in 0u32..6,
1127 cwe_id in any::<u32>(),
1128 line_number in any::<u32>()
1129 ) {
1130 let finding = RestFinding {
1131 issue_id,
1132 scan_type: "STATIC".to_string(),
1133 description: "Test".to_string(),
1134 count,
1135 context_type: "POLICY".to_string(),
1136 context_guid: "guid".to_string(),
1137 violates_policy: true,
1138 finding_status: FindingStatus {
1139 first_found_date: "2024-01-01".to_string(),
1140 status: "OPEN".to_string(),
1141 resolution: "UNRESOLVED".to_string(),
1142 mitigation_review_status: "NONE".to_string(),
1143 new: true,
1144 resolution_status: "UNRESOLVED".to_string(),
1145 last_seen_date: "2024-01-01".to_string(),
1146 },
1147 finding_details: FindingDetails {
1148 severity,
1149 cwe: CweInfo {
1150 id: cwe_id,
1151 name: "Test CWE".to_string(),
1152 href: "https://example.com".to_string(),
1153 },
1154 file_path: "/test".to_string(),
1155 file_name: "test.rs".to_string(),
1156 module: "test".to_string(),
1157 relative_location: 0,
1158 finding_category: FindingCategory {
1159 id: 1,
1160 name: "Test".to_string(),
1161 href: "https://example.com".to_string(),
1162 },
1163 procedure: "test".to_string(),
1164 exploitability: 0,
1165 attack_vector: "Remote".to_string(),
1166 file_line_number: line_number,
1167 },
1168 build_id,
1169 };
1170
1171 let json = serde_json::to_string(&finding).expect("serialization should succeed");
1173
1174 let deserialized: RestFinding = serde_json::from_str(&json)
1176 .expect("deserialization should succeed");
1177
1178 assert_eq!(deserialized.issue_id, issue_id);
1180 assert_eq!(deserialized.count, count);
1181 assert_eq!(deserialized.build_id, build_id);
1182 assert_eq!(deserialized.finding_details.severity, severity);
1183 assert_eq!(deserialized.finding_details.file_line_number, line_number);
1184 }
1185
1186 #[test]
1190 fn prop_page_info_arithmetic_safe(
1191 size in any::<u32>(),
1192 total_elements in any::<u32>(),
1193 total_pages in any::<u32>(),
1194 number in any::<u32>()
1195 ) {
1196 let page = PageInfo {
1197 size,
1198 total_elements,
1199 total_pages,
1200 number,
1201 };
1202
1203 let json = serde_json::to_string(&page).expect("serialization should succeed");
1205 let deserialized: PageInfo = serde_json::from_str(&json)
1206 .expect("deserialization should succeed");
1207
1208 assert_eq!(deserialized.size, size);
1209 assert_eq!(deserialized.total_elements, total_elements);
1210 assert_eq!(deserialized.total_pages, total_pages);
1211 assert_eq!(deserialized.number, number);
1212 }
1213 }
1214
1215 fn create_test_response(current_page: u32, total_pages: u32) -> FindingsResponse {
1220 FindingsResponse {
1221 embedded: FindingsEmbedded { findings: vec![] },
1222 links: create_test_links(),
1223 page: PageInfo {
1224 size: 100,
1225 total_elements: 1000,
1226 total_pages,
1227 number: current_page,
1228 },
1229 }
1230 }
1231
1232 fn create_test_links() -> FindingsLinks {
1233 FindingsLinks {
1234 first: Some(HalLink {
1235 href: "first".to_string(),
1236 templated: None,
1237 }),
1238 self_link: HalLink {
1239 href: "self".to_string(),
1240 templated: None,
1241 },
1242 next: Some(HalLink {
1243 href: "next".to_string(),
1244 templated: None,
1245 }),
1246 last: Some(HalLink {
1247 href: "last".to_string(),
1248 templated: None,
1249 }),
1250 application: HalLink {
1251 href: "app".to_string(),
1252 templated: None,
1253 },
1254 sca: None,
1255 sandbox: None,
1256 }
1257 }
1258
1259 fn create_test_finding(id: u32) -> RestFinding {
1260 RestFinding {
1261 issue_id: id,
1262 scan_type: "STATIC".to_string(),
1263 description: format!("Test finding {}", id),
1264 count: 1,
1265 context_type: "POLICY".to_string(),
1266 context_guid: "test-guid".to_string(),
1267 violates_policy: true,
1268 finding_status: FindingStatus {
1269 first_found_date: "2024-01-01".to_string(),
1270 status: "OPEN".to_string(),
1271 resolution: "UNRESOLVED".to_string(),
1272 mitigation_review_status: "NONE".to_string(),
1273 new: true,
1274 resolution_status: "UNRESOLVED".to_string(),
1275 last_seen_date: "2024-01-01".to_string(),
1276 },
1277 finding_details: FindingDetails {
1278 severity: 3,
1279 cwe: CweInfo {
1280 id: 79,
1281 name: "Cross-site Scripting".to_string(),
1282 href: "https://cwe.mitre.org/data/definitions/79.html".to_string(),
1283 },
1284 file_path: "/src/test.rs".to_string(),
1285 file_name: "test.rs".to_string(),
1286 module: "test".to_string(),
1287 relative_location: 10,
1288 finding_category: FindingCategory {
1289 id: 1,
1290 name: "Security".to_string(),
1291 href: "https://example.com".to_string(),
1292 },
1293 procedure: "test_function".to_string(),
1294 exploitability: 3,
1295 attack_vector: "Remote".to_string(),
1296 file_line_number: 42,
1297 },
1298 build_id: 12345,
1299 }
1300 }
1301}