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::InvalidConfig(_)
427 | VeracodeError::RetryExhausted(_)
428 | VeracodeError::RateLimited { .. }
429 | VeracodeError::Validation(_),
430 _,
431 ) => FindingsError::RequestFailed { source: e },
432 })?;
433
434 let response_text = response
436 .text()
437 .await
438 .map_err(|e| FindingsError::RequestFailed {
439 source: VeracodeError::Http(e),
440 })?;
441
442 let char_count = response_text.chars().count();
443 if char_count > 500 {
444 let truncated: String = response_text.chars().take(500).collect();
445 debug!(
446 "Raw API response (first 500 chars): {}... [truncated {} more characters]",
447 truncated,
448 char_count.saturating_sub(500)
449 );
450 } else {
451 debug!("Raw API response: {response_text}");
452 }
453
454 validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
456 error!("JSON validation failed: {e}");
457 FindingsError::RequestFailed {
458 source: VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e)),
459 }
460 })?;
461
462 let findings_response: FindingsResponse =
463 serde_json::from_str(&response_text).map_err(|e| {
464 error!("JSON parsing error: {e}");
465 debug!("Full response that failed to parse: {response_text}");
466 FindingsError::RequestFailed {
467 source: VeracodeError::Serialization(e),
468 }
469 })?;
470
471 debug!(
472 "Retrieved {} findings on page {}/{}",
473 findings_response.findings().len(),
474 findings_response.current_page().saturating_add(1),
475 findings_response.total_pages()
476 );
477
478 Ok(findings_response)
479 }
480
481 pub async fn get_all_findings(
488 &self,
489 query: &FindingsQuery<'_>,
490 ) -> Result<Vec<RestFinding>, FindingsError> {
491 debug!("Getting all findings for app: {}", query.app_guid);
492
493 let mut all_findings = Vec::new();
494 let mut current_page = 0;
495 let page_size = 500; loop {
498 let mut page_query = query.clone();
499 page_query.page = Some(current_page);
500 page_query.size = Some(page_size);
501
502 let response = self.get_findings(&page_query).await?;
503
504 if response.findings().is_empty() {
505 debug!("No more findings found on page {current_page}");
506 break;
507 }
508
509 let page_findings = response.findings().len();
510 all_findings.extend_from_slice(response.findings());
511
512 debug!(
513 "Added {} findings from page {}, total so far: {}",
514 page_findings,
515 current_page,
516 all_findings.len()
517 );
518
519 if response.is_last_page() {
521 debug!("Reached last page ({current_page}), stopping");
522 break;
523 }
524
525 current_page = current_page.saturating_add(1);
526
527 if current_page > 1000 {
529 warn!(
530 "Reached maximum page limit (1000) while fetching findings for app: {}",
531 query.app_guid
532 );
533 break;
534 }
535 }
536
537 debug!(
538 "Retrieved total of {} findings across {} pages",
539 all_findings.len(),
540 current_page.saturating_add(1)
541 );
542 Ok(all_findings)
543 }
544
545 pub async fn get_policy_findings(
552 &self,
553 app_guid: &str,
554 ) -> Result<FindingsResponse, FindingsError> {
555 self.get_findings(&FindingsQuery::new(app_guid)).await
556 }
557
558 pub async fn get_sandbox_findings(
565 &self,
566 app_guid: &str,
567 sandbox_guid: &str,
568 ) -> Result<FindingsResponse, FindingsError> {
569 self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
570 .await
571 }
572
573 pub async fn get_all_policy_findings(
580 &self,
581 app_guid: &str,
582 ) -> Result<Vec<RestFinding>, FindingsError> {
583 self.get_all_findings(&FindingsQuery::new(app_guid)).await
584 }
585
586 pub async fn get_all_sandbox_findings(
593 &self,
594 app_guid: &str,
595 sandbox_guid: &str,
596 ) -> Result<Vec<RestFinding>, FindingsError> {
597 self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
598 .await
599 }
600}
601
602#[cfg(test)]
603#[allow(clippy::expect_used)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn test_findings_query_builder() {
609 let query = FindingsQuery::new("app-123")
610 .with_pagination(0, 50)
611 .with_severity(vec![3, 4, 5])
612 .policy_violations_only();
613
614 assert_eq!(query.app_guid, "app-123");
615 assert_eq!(query.page, Some(0));
616 assert_eq!(query.size, Some(50));
617 assert_eq!(query.severity, Some(vec![3, 4, 5]));
618 assert_eq!(query.violates_policy, Some(true));
619 assert!(query.context.is_none());
620 }
621
622 #[test]
623 fn test_sandbox_query_builder() {
624 let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
625
626 assert_eq!(query.app_guid, "app-123");
627 assert_eq!(
628 query.context.as_ref().expect("should have context"),
629 "sandbox-456"
630 );
631 assert_eq!(query.page, Some(1));
632 assert_eq!(query.size, Some(100));
633 }
634
635 #[test]
636 fn test_findings_response_helpers() {
637 let response = FindingsResponse {
638 embedded: FindingsEmbedded {
639 findings: vec![], },
641 links: FindingsLinks {
642 first: Some(HalLink {
643 href: "first".to_string(),
644 templated: None,
645 }),
646 self_link: HalLink {
647 href: "self".to_string(),
648 templated: None,
649 },
650 next: Some(HalLink {
651 href: "next".to_string(),
652 templated: None,
653 }),
654 last: Some(HalLink {
655 href: "last".to_string(),
656 templated: None,
657 }),
658 application: HalLink {
659 href: "app".to_string(),
660 templated: None,
661 },
662 sca: None,
663 sandbox: None,
664 },
665 page: PageInfo {
666 size: 20,
667 total_elements: 100,
668 total_pages: 5,
669 number: 2,
670 },
671 };
672
673 assert_eq!(response.current_page(), 2);
674 assert_eq!(response.total_pages(), 5);
675 assert_eq!(response.total_elements(), 100);
676 assert!(response.has_next_page());
677 assert!(!response.is_last_page());
678 }
679}
680
681#[cfg(test)]
686#[allow(clippy::expect_used)]
687mod proptest_security {
688 use super::*;
689 use proptest::prelude::*;
690
691 proptest! {
696 #![proptest_config(ProptestConfig {
697 cases: if cfg!(miri) { 5 } else { 1000 },
698 failure_persistence: None,
699 .. ProptestConfig::default()
700 })]
701
702 #[test]
709 fn prop_is_last_page_handles_overflow(
710 current_page in any::<u32>(),
711 total_pages in any::<u32>()
712 ) {
713 let response = create_test_response(current_page, total_pages);
714
715 let is_last = response.is_last_page();
717
718 if current_page == u32::MAX {
720 assert!(is_last);
723 } else if total_pages == 0 {
724 assert!(is_last);
726 } else {
727 let expected = current_page.saturating_add(1) >= total_pages;
729 assert_eq!(is_last, expected);
730 }
731 }
732
733 #[test]
737 fn prop_has_next_page_consistency(
738 current_page in 0u32..1000u32,
739 total_pages in 1u32..1001u32
740 ) {
741 let mut response = create_test_response(current_page, total_pages);
742
743 if response.is_last_page() {
745 response.links.next = None;
746 }
747
748 let has_next = response.has_next_page();
749 let is_last = response.is_last_page();
750
751 if response.links.next.is_some() {
753 assert!(has_next);
754 } else {
755 assert!(!has_next);
756 }
757
758 if !is_last && response.links.next.is_some() {
760 assert!(has_next);
761 }
762
763 response.links.next = None;
765 assert!(!response.has_next_page());
766 }
767
768 #[test]
772 fn prop_page_accessors_never_panic(
773 current in any::<u32>(),
774 total in any::<u32>(),
775 elements in any::<u32>()
776 ) {
777 let mut response = create_test_response(current, total);
778 response.page.total_elements = elements;
779
780 let _ = response.current_page();
782 let _ = response.total_pages();
783 let _ = response.total_elements();
784 let _ = response.is_last_page();
785 let _ = response.has_next_page();
786 }
787 }
788
789 proptest! {
794 #![proptest_config(ProptestConfig {
795 cases: if cfg!(miri) { 5 } else { 1000 },
796 failure_persistence: None,
797 .. ProptestConfig::default()
798 })]
799
800 #[test]
805 fn prop_query_builder_handles_malicious_strings(
806 app_guid in "\\PC*",
807 sandbox_guid in "\\PC*",
808 scan_type in "\\PC*"
809 ) {
810 let query = FindingsQuery::new(&app_guid);
812 assert_eq!(query.app_guid.as_ref(), &app_guid);
813
814 let query = FindingsQuery::for_sandbox(&app_guid, &sandbox_guid);
815 assert_eq!(query.app_guid.as_ref(), &app_guid);
816 assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
817
818 let query = query.with_scan_type(&scan_type);
819 assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
820 }
821
822 #[test]
826 fn prop_severity_filter_accepts_any_u32(
827 severity_values in prop::collection::vec(any::<u32>(), 0..10)
828 ) {
829 let query = FindingsQuery::new("test-app")
830 .with_severity(severity_values.clone());
831
832 assert_eq!(query.severity, Some(severity_values));
834 }
835
836 #[test]
840 fn prop_cwe_filter_handles_arbitrary_strings(
841 cwe_ids in prop::collection::vec("\\PC*", 0..20)
842 ) {
843 let query = FindingsQuery::new("test-app")
844 .with_cwe(cwe_ids.clone());
845
846 assert_eq!(query.cwe_id, Some(cwe_ids));
847 }
848
849 #[test]
853 fn prop_pagination_parameters_safe(
854 page in any::<u32>(),
855 size in any::<u32>()
856 ) {
857 let query = FindingsQuery::new("test-app")
858 .with_pagination(page, size);
859
860 assert_eq!(query.page, Some(page));
861 assert_eq!(query.size, Some(size));
862
863 if let (Some(p), Some(s)) = (query.page, query.size) {
865 assert_eq!(p, page);
866 assert_eq!(s, size);
867 }
868 }
869 }
870
871 proptest! {
876 #![proptest_config(ProptestConfig {
877 cases: if cfg!(miri) { 5 } else { 1000 },
878 failure_persistence: None,
879 .. ProptestConfig::default()
880 })]
881
882 #[test]
886 fn prop_string_truncation_utf8_safe(
887 text in "\\PC{0,2000}"
888 ) {
889 let char_count = text.chars().count();
890
891 if char_count > 500 {
893 let truncated: String = text.chars().take(500).collect();
894 let remaining = char_count.saturating_sub(500);
895
896 assert!(truncated.is_ascii() || std::str::from_utf8(truncated.as_bytes()).is_ok());
899
900 assert!(truncated.chars().count() <= 500);
902
903 assert_eq!(remaining, char_count.saturating_sub(500));
905
906 assert!(remaining <= char_count);
908 }
909 }
910
911 #[test]
915 #[allow(clippy::arithmetic_side_effects)] fn prop_saturating_sub_prevents_underflow(
917 a in any::<u32>(),
918 b in any::<u32>()
919 ) {
920 let result = a.saturating_sub(b);
921
922 assert!(result <= a);
924
925 if a >= b {
927 assert_eq!(result, a - b);
928 } else {
929 assert_eq!(result, 0);
931 }
932 }
933 }
934
935 proptest! {
940 #![proptest_config(ProptestConfig {
941 cases: if cfg!(miri) { 5 } else { 500 },
942 failure_persistence: None,
943 .. ProptestConfig::default()
944 })]
945
946 #[test]
950 #[allow(clippy::cast_possible_truncation)] fn prop_query_params_vector_safe(
952 severity_count in 0usize..100,
953 cwe_count in 0usize..100
954 ) {
955 let query = FindingsQuery::new("test-app")
956 .with_severity((0..severity_count).map(|i| i as u32).collect())
957 .with_cwe((0..cwe_count).map(|i| format!("CWE-{}", i)).collect());
958
959 if let Some(ref severity) = query.severity {
961 assert_eq!(severity.len(), severity_count);
962 }
963
964 if let Some(ref cwe_ids) = query.cwe_id {
965 assert_eq!(cwe_ids.len(), cwe_count);
966 }
967 }
968
969 #[test]
973 #[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn prop_findings_list_memory_safe(
976 finding_count in 0usize..1000
977 ) {
978 let findings: Vec<RestFinding> = (0..finding_count)
979 .map(|i| create_test_finding(i as u32))
980 .collect();
981
982 let response = FindingsResponse {
983 embedded: FindingsEmbedded { findings },
984 links: create_test_links(),
985 page: PageInfo {
986 size: 100,
987 total_elements: finding_count as u32,
988 total_pages: (finding_count / 100) as u32 + 1,
989 number: 0,
990 },
991 };
992
993 let findings_slice = response.findings();
995 assert_eq!(findings_slice.len(), finding_count);
996 }
997 }
998
999 proptest! {
1004 #![proptest_config(ProptestConfig {
1005 cases: if cfg!(miri) { 5 } else { 500 },
1006 failure_persistence: None,
1007 .. ProptestConfig::default()
1008 })]
1009
1010 #[test]
1014 fn prop_error_display_safe(
1015 app_guid in "[a-zA-Z0-9\\-]{1,100}",
1016 sandbox_guid in "[a-zA-Z0-9\\-]{1,100}",
1017 page in any::<u32>(),
1018 size in any::<u32>()
1019 ) {
1020 let err1 = FindingsError::ApplicationNotFound {
1022 app_guid: app_guid.clone()
1023 };
1024 let msg1 = format!("{}", err1);
1025 assert!(msg1.contains(&app_guid));
1026
1027 let err2 = FindingsError::SandboxNotFound {
1028 app_guid: app_guid.clone(),
1029 sandbox_guid: sandbox_guid.clone(),
1030 };
1031 let msg2 = format!("{}", err2);
1032 assert!(msg2.contains(&app_guid));
1033 assert!(msg2.contains(&sandbox_guid));
1034
1035 let err3 = FindingsError::InvalidPagination { page, size };
1036 let msg3 = format!("{}", err3);
1037 assert!(msg3.contains(&page.to_string()));
1038 assert!(msg3.contains(&size.to_string()));
1039
1040 let err4 = FindingsError::NoFindings;
1041 let msg4 = format!("{}", err4);
1042 assert!(!msg4.is_empty());
1043 }
1044 }
1045
1046 proptest! {
1051 #![proptest_config(ProptestConfig {
1052 cases: if cfg!(miri) { 5 } else { 500 },
1053 failure_persistence: None,
1054 .. ProptestConfig::default()
1055 })]
1056
1057 #[test]
1061 fn prop_builder_chain_preserves_data(
1062 app_guid in "[a-zA-Z0-9\\-]{1,50}",
1063 sandbox_guid in "[a-zA-Z0-9\\-]{1,50}",
1064 page in 0u32..1000,
1065 size in 1u32..1000,
1066 scan_type in "[A-Z]{1,20}"
1067 ) {
1068 let query = FindingsQuery::new(&app_guid)
1069 .with_sandbox(&sandbox_guid)
1070 .with_pagination(page, size)
1071 .with_scan_type(&scan_type)
1072 .policy_violations_only();
1073
1074 assert_eq!(query.app_guid.as_ref(), &app_guid);
1076 assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
1077 assert_eq!(query.page, Some(page));
1078 assert_eq!(query.size, Some(size));
1079 assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
1080 assert_eq!(query.violates_policy, Some(true));
1081 }
1082
1083 #[test]
1087 fn prop_query_clone_independence(
1088 app_guid in "[a-zA-Z0-9\\-]{1,50}"
1089 ) {
1090 let query1 = FindingsQuery::new(&app_guid)
1091 .with_pagination(0, 100);
1092
1093 let query2 = query1.clone();
1094
1095 assert_eq!(query1.app_guid, query2.app_guid);
1097 assert_eq!(query1.page, query2.page);
1098
1099 let query3 = query2.with_pagination(1, 200);
1101 assert_eq!(query3.page, Some(1));
1102 }
1104 }
1105
1106 proptest! {
1111 #![proptest_config(ProptestConfig {
1112 cases: if cfg!(miri) { 5 } else { 500 },
1113 failure_persistence: None,
1114 .. ProptestConfig::default()
1115 })]
1116
1117 #[test]
1121 fn prop_serde_roundtrip_safe(
1122 issue_id in any::<u32>(),
1123 count in any::<u32>(),
1124 build_id in any::<u64>(),
1125 severity in 0u32..6,
1126 cwe_id in any::<u32>(),
1127 line_number in any::<u32>()
1128 ) {
1129 let finding = RestFinding {
1130 issue_id,
1131 scan_type: "STATIC".to_string(),
1132 description: "Test".to_string(),
1133 count,
1134 context_type: "POLICY".to_string(),
1135 context_guid: "guid".to_string(),
1136 violates_policy: true,
1137 finding_status: FindingStatus {
1138 first_found_date: "2024-01-01".to_string(),
1139 status: "OPEN".to_string(),
1140 resolution: "UNRESOLVED".to_string(),
1141 mitigation_review_status: "NONE".to_string(),
1142 new: true,
1143 resolution_status: "UNRESOLVED".to_string(),
1144 last_seen_date: "2024-01-01".to_string(),
1145 },
1146 finding_details: FindingDetails {
1147 severity,
1148 cwe: CweInfo {
1149 id: cwe_id,
1150 name: "Test CWE".to_string(),
1151 href: "https://example.com".to_string(),
1152 },
1153 file_path: "/test".to_string(),
1154 file_name: "test.rs".to_string(),
1155 module: "test".to_string(),
1156 relative_location: 0,
1157 finding_category: FindingCategory {
1158 id: 1,
1159 name: "Test".to_string(),
1160 href: "https://example.com".to_string(),
1161 },
1162 procedure: "test".to_string(),
1163 exploitability: 0,
1164 attack_vector: "Remote".to_string(),
1165 file_line_number: line_number,
1166 },
1167 build_id,
1168 };
1169
1170 let json = serde_json::to_string(&finding).expect("serialization should succeed");
1172
1173 let deserialized: RestFinding = serde_json::from_str(&json)
1175 .expect("deserialization should succeed");
1176
1177 assert_eq!(deserialized.issue_id, issue_id);
1179 assert_eq!(deserialized.count, count);
1180 assert_eq!(deserialized.build_id, build_id);
1181 assert_eq!(deserialized.finding_details.severity, severity);
1182 assert_eq!(deserialized.finding_details.file_line_number, line_number);
1183 }
1184
1185 #[test]
1189 fn prop_page_info_arithmetic_safe(
1190 size in any::<u32>(),
1191 total_elements in any::<u32>(),
1192 total_pages in any::<u32>(),
1193 number in any::<u32>()
1194 ) {
1195 let page = PageInfo {
1196 size,
1197 total_elements,
1198 total_pages,
1199 number,
1200 };
1201
1202 let json = serde_json::to_string(&page).expect("serialization should succeed");
1204 let deserialized: PageInfo = serde_json::from_str(&json)
1205 .expect("deserialization should succeed");
1206
1207 assert_eq!(deserialized.size, size);
1208 assert_eq!(deserialized.total_elements, total_elements);
1209 assert_eq!(deserialized.total_pages, total_pages);
1210 assert_eq!(deserialized.number, number);
1211 }
1212 }
1213
1214 fn create_test_response(current_page: u32, total_pages: u32) -> FindingsResponse {
1219 FindingsResponse {
1220 embedded: FindingsEmbedded { findings: vec![] },
1221 links: create_test_links(),
1222 page: PageInfo {
1223 size: 100,
1224 total_elements: 1000,
1225 total_pages,
1226 number: current_page,
1227 },
1228 }
1229 }
1230
1231 fn create_test_links() -> FindingsLinks {
1232 FindingsLinks {
1233 first: Some(HalLink {
1234 href: "first".to_string(),
1235 templated: None,
1236 }),
1237 self_link: HalLink {
1238 href: "self".to_string(),
1239 templated: None,
1240 },
1241 next: Some(HalLink {
1242 href: "next".to_string(),
1243 templated: None,
1244 }),
1245 last: Some(HalLink {
1246 href: "last".to_string(),
1247 templated: None,
1248 }),
1249 application: HalLink {
1250 href: "app".to_string(),
1251 templated: None,
1252 },
1253 sca: None,
1254 sandbox: None,
1255 }
1256 }
1257
1258 fn create_test_finding(id: u32) -> RestFinding {
1259 RestFinding {
1260 issue_id: id,
1261 scan_type: "STATIC".to_string(),
1262 description: format!("Test finding {}", id),
1263 count: 1,
1264 context_type: "POLICY".to_string(),
1265 context_guid: "test-guid".to_string(),
1266 violates_policy: true,
1267 finding_status: FindingStatus {
1268 first_found_date: "2024-01-01".to_string(),
1269 status: "OPEN".to_string(),
1270 resolution: "UNRESOLVED".to_string(),
1271 mitigation_review_status: "NONE".to_string(),
1272 new: true,
1273 resolution_status: "UNRESOLVED".to_string(),
1274 last_seen_date: "2024-01-01".to_string(),
1275 },
1276 finding_details: FindingDetails {
1277 severity: 3,
1278 cwe: CweInfo {
1279 id: 79,
1280 name: "Cross-site Scripting".to_string(),
1281 href: "https://cwe.mitre.org/data/definitions/79.html".to_string(),
1282 },
1283 file_path: "/src/test.rs".to_string(),
1284 file_name: "test.rs".to_string(),
1285 module: "test".to_string(),
1286 relative_location: 10,
1287 finding_category: FindingCategory {
1288 id: 1,
1289 name: "Security".to_string(),
1290 href: "https://example.com".to_string(),
1291 },
1292 procedure: "test_function".to_string(),
1293 exploitability: 3,
1294 attack_vector: "Remote".to_string(),
1295 file_line_number: 42,
1296 },
1297 build_id: 12345,
1298 }
1299 }
1300}