Skip to main content

buildfix_adapter_sdk/
harness.rs

1//! Test harness for validating adapter implementations.
2//!
3//! This module provides `AdapterTestHarness` which helps validate that adapter
4//! implementations correctly produce receipts that conform to buildfix expectations.
5//!
6//! # Metadata Validation
7//!
8//! Use [`AdapterTestHarness::validate_metadata`] to ensure adapters properly
9//! implement the [`AdapterMetadata`](crate::AdapterMetadata) trait:
10//!
11//! ```ignore
12//! use buildfix_adapter_sdk::{AdapterTestHarness, AdapterMetadata};
13//!
14//! #[test]
15//! fn test_metadata() {
16//!     let harness = AdapterTestHarness::new(MyAdapter::new());
17//!     harness.validate_metadata(&harness.adapter()).expect("metadata should be valid");
18//! }
19//! ```
20
21use crate::{Adapter, AdapterError, AdapterMetadata};
22use buildfix_types::receipt::{ReceiptEnvelope, Severity};
23use std::collections::HashSet;
24use std::path::Path;
25
26/// Error returned when adapter metadata validation fails.
27///
28/// This error is produced by [`AdapterTestHarness::validate_metadata`] when
29/// an adapter's metadata does not meet the required constraints.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct MetadataValidationError {
32    /// The field that failed validation.
33    pub field: &'static str,
34    /// A human-readable description of the validation failure.
35    pub message: &'static str,
36}
37
38impl std::fmt::Display for MetadataValidationError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(
41            f,
42            "Metadata validation failed for '{}': {}",
43            self.field, self.message
44        )
45    }
46}
47
48impl std::error::Error for MetadataValidationError {}
49
50/// Validation error with context.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ValidationError {
53    /// The field that failed validation
54    pub field: String,
55    /// The invalid value
56    pub value: String,
57    /// Human-readable error message
58    pub message: String,
59}
60
61impl std::fmt::Display for ValidationError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "Validation error on '{}': {} (value: {:?})",
66            self.field, self.message, self.value
67        )
68    }
69}
70
71impl std::error::Error for ValidationError {}
72
73#[derive(Debug, Default)]
74pub struct ValidationResult {
75    errors: Vec<ValidationError>,
76}
77
78impl ValidationResult {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    pub fn add_error(
84        &mut self,
85        field: impl Into<String>,
86        value: impl Into<String>,
87        message: impl Into<String>,
88    ) {
89        self.errors.push(ValidationError {
90            field: field.into(),
91            value: value.into(),
92            message: message.into(),
93        });
94    }
95
96    pub fn is_valid(&self) -> bool {
97        self.errors.is_empty()
98    }
99
100    pub fn errors(&self) -> &[ValidationError] {
101        &self.errors
102    }
103
104    pub fn expect_valid(self) -> Result<(), Vec<ValidationError>> {
105        if self.errors.is_empty() {
106            Ok(())
107        } else {
108            Err(self.errors)
109        }
110    }
111}
112
113pub struct AdapterTestHarness<A: Adapter> {
114    adapter: A,
115}
116
117impl<A: Adapter> AdapterTestHarness<A> {
118    pub fn new(adapter: A) -> Self {
119        Self { adapter }
120    }
121
122    pub fn adapter(&self) -> &A {
123        &self.adapter
124    }
125
126    pub fn validate_receipt(&self, receipt: &ReceiptEnvelope) -> ValidationResult {
127        let mut result = ValidationResult::new();
128
129        if receipt.schema.is_empty() {
130            result.add_error("schema", "", "schema must not be empty");
131        }
132
133        if receipt.tool.name.is_empty() {
134            result.add_error("tool.name", "", "tool name must not be empty");
135        }
136
137        result
138    }
139
140    pub fn validate_receipt_fixture(
141        &self,
142        fixture_path: impl AsRef<Path>,
143    ) -> Result<ReceiptEnvelope, AdapterError> {
144        let path = fixture_path.as_ref();
145        let receipt = self.adapter.load(path)?;
146
147        let validation = self.validate_receipt(&receipt);
148        if !validation.is_valid() {
149            for err in validation.errors() {
150                eprintln!("Validation warning: {}", err);
151            }
152        }
153
154        Ok(receipt)
155    }
156
157    pub fn validate_finding_fields(&self, receipt: &ReceiptEnvelope) -> ValidationResult {
158        let mut result = ValidationResult::new();
159
160        for (i, finding) in receipt.findings.iter().enumerate() {
161            if finding.message.is_none() && finding.data.is_none() {
162                result.add_error(
163                    format!("finding[{}]", i),
164                    "",
165                    "finding must have either message or data",
166                );
167            }
168
169            if let Some(ref loc) = finding.location
170                && loc.path.as_str().is_empty()
171            {
172                result.add_error(
173                    format!("finding[{}].location.path", i),
174                    "",
175                    "location path must not be empty",
176                );
177            }
178
179            if finding.severity == Severity::Error && finding.check_id.is_none() {
180                result.add_error(
181                    format!("finding[{}]", i),
182                    "",
183                    "error findings should have a check_id for actionable fixes",
184                );
185            }
186        }
187
188        result
189    }
190
191    pub fn golden_test(
192        &self,
193        fixture_path: impl AsRef<Path>,
194        expected: &ReceiptEnvelope,
195    ) -> Result<(), Box<dyn std::error::Error>> {
196        let actual = self.validate_receipt_fixture(fixture_path)?;
197
198        let expected_json = serde_json::to_string_pretty(expected)?;
199        let actual_json = serde_json::to_string_pretty(&actual)?;
200
201        assert_eq!(
202            expected_json, actual_json,
203            "Golden test failed: receipt does not match expected"
204        );
205
206        Ok(())
207    }
208
209    pub fn assert_finding_count(
210        &self,
211        receipt: &ReceiptEnvelope,
212        expected: usize,
213        severity: Option<Severity>,
214    ) -> Result<(), AdapterError> {
215        let actual = match severity {
216            Some(s) => receipt.findings.iter().filter(|f| f.severity == s).count(),
217            None => receipt.findings.len(),
218        };
219
220        if actual != expected {
221            return Err(AdapterError::InvalidFormat(format!(
222                "Expected {} findings (severity: {:?}), found {}",
223                expected, severity, actual
224            )));
225        }
226
227        Ok(())
228    }
229
230    pub fn assert_has_check_id(
231        &self,
232        receipt: &ReceiptEnvelope,
233        check_id: &str,
234    ) -> Result<(), AdapterError> {
235        let found = receipt
236            .findings
237            .iter()
238            .any(|f| f.check_id.as_deref() == Some(check_id));
239
240        if !found {
241            return Err(AdapterError::InvalidFormat(format!(
242                "Expected finding with check_id '{}', but none found",
243                check_id
244            )));
245        }
246
247        Ok(())
248    }
249
250    pub fn extract_check_ids(&self, receipt: &ReceiptEnvelope) -> HashSet<String> {
251        receipt
252            .findings
253            .iter()
254            .filter_map(|f| f.check_id.clone())
255            .collect()
256    }
257
258    /// Validates adapter metadata is properly configured.
259    ///
260    /// This method checks that the adapter's [`AdapterMetadata`] implementation
261    /// returns valid, non-empty values for all required fields:
262    ///
263    /// - `name` must not be empty
264    /// - `version` must not be empty
265    /// - `supported_schemas` must contain at least one schema
266    ///
267    /// # Arguments
268    ///
269    /// * `adapter` - A reference to any type implementing [`AdapterMetadata`]
270    ///
271    /// # Returns
272    ///
273    /// `Ok(())` if all metadata validations pass.
274    ///
275    /// # Errors
276    ///
277    /// Returns a [`MetadataValidationError`] with the field name and error message
278    /// if any validation fails.
279    ///
280    /// # Example
281    ///
282    /// ```ignore
283    /// use buildfix_adapter_sdk::{AdapterTestHarness, AdapterMetadata};
284    ///
285    /// struct MyAdapter;
286    /// impl AdapterMetadata for MyAdapter {
287    ///     fn name(&self) -> &str { "my-adapter" }
288    ///     fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
289    ///     fn supported_schemas(&self) -> &[&str] { &["my-adapter.report.v1"] }
290    /// }
291    ///
292    /// let harness = AdapterTestHarness::new(MyAdapter);
293    /// harness.validate_metadata(&MyAdapter)
294    ///     .expect("adapter metadata should be valid");
295    /// ```
296    pub fn validate_metadata<M: AdapterMetadata>(
297        &self,
298        adapter: &M,
299    ) -> Result<(), MetadataValidationError> {
300        if adapter.name().is_empty() {
301            return Err(MetadataValidationError {
302                field: "name",
303                message: "adapter name must not be empty",
304            });
305        }
306
307        if adapter.version().is_empty() {
308            return Err(MetadataValidationError {
309                field: "version",
310                message: "adapter version must not be empty",
311            });
312        }
313
314        if adapter.supported_schemas().is_empty() {
315            return Err(MetadataValidationError {
316                field: "supported_schemas",
317                message: "adapter must support at least one schema",
318            });
319        }
320
321        Ok(())
322    }
323
324    /// Validates that all check IDs follow the naming convention: `sensor.category.specific`
325    ///
326    /// # Arguments
327    /// * `receipt` - The receipt to validate
328    ///
329    /// # Returns
330    /// * `Ok(())` if all check IDs are valid
331    /// * `Err(Vec<ValidationError>)` with details of invalid IDs
332    ///
333    /// # Check ID Format Rules
334    /// - Must be lowercase
335    /// - Must contain at least 2 dots (3+ segments)
336    /// - Each segment must be snake_case alphanumeric (with hyphens allowed)
337    /// - Examples: `cargo-deny.ban.multiple-versions`, `machete.unused_dependency`
338    pub fn validate_check_id_format(
339        &self,
340        receipt: &ReceiptEnvelope,
341    ) -> Result<(), Vec<ValidationError>> {
342        let mut errors = Vec::new();
343
344        for (i, finding) in receipt.findings.iter().enumerate() {
345            if let Some(ref check_id) = finding.check_id
346                && !Self::is_valid_check_id(check_id)
347            {
348                errors.push(ValidationError {
349                    field: format!("finding[{}].check_id", i),
350                    value: check_id.clone(),
351                    message: "check_id must follow naming convention: lowercase, at least 2 dots (3+ segments), each segment must be snake_case alphanumeric (e.g., 'cargo-deny.ban.multiple-versions')".to_string(),
352                });
353            }
354        }
355
356        if errors.is_empty() {
357            Ok(())
358        } else {
359            Err(errors)
360        }
361    }
362
363    /// Checks if a check ID follows the naming convention.
364    ///
365    /// Valid format: `sensor.category.specific` where:
366    /// - All lowercase
367    /// - At least 2 dots (3+ segments)
368    /// - Each segment is alphanumeric with hyphens or underscores allowed
369    fn is_valid_check_id(check_id: &str) -> bool {
370        // Must be lowercase
371        if check_id != check_id.to_lowercase() {
372            return false;
373        }
374
375        // Must contain at least 2 dots (3+ segments)
376        let segments: Vec<&str> = check_id.split('.').collect();
377        if segments.len() < 3 {
378            return false;
379        }
380
381        // Each segment must be non-empty and contain only valid characters
382        for segment in segments {
383            if segment.is_empty() {
384                return false;
385            }
386            // Allow alphanumeric, hyphens, and underscores
387            if !segment
388                .chars()
389                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
390            {
391                return false;
392            }
393        }
394
395        true
396    }
397
398    /// Validates that all location paths in findings are well-formed.
399    ///
400    /// # Arguments
401    /// * `receipt` - The receipt to validate
402    ///
403    /// # Returns
404    /// * `Ok(())` if all paths are valid
405    /// * `Err(Vec<ValidationError>)` with details of invalid paths
406    ///
407    /// # Path Validation Rules
408    /// - Must not be empty
409    /// - Must use forward slashes (not backslashes)
410    /// - Must not start with `/` (relative paths only)
411    /// - Must not contain `..` (no parent directory traversal)
412    /// - Examples: `src/main.rs`, `Cargo.toml`, `crates/domain/src/lib.rs`
413    pub fn validate_location_paths(
414        &self,
415        receipt: &ReceiptEnvelope,
416    ) -> Result<(), Vec<ValidationError>> {
417        let mut errors = Vec::new();
418
419        for (i, finding) in receipt.findings.iter().enumerate() {
420            if let Some(ref location) = finding.location {
421                let path = location.path.as_str();
422
423                // Must not be empty
424                if path.is_empty() {
425                    errors.push(ValidationError {
426                        field: format!("finding[{}].location.path", i),
427                        value: path.to_string(),
428                        message: "location path must not be empty".to_string(),
429                    });
430                    continue;
431                }
432
433                // Must not contain backslashes
434                if path.contains('\\') {
435                    errors.push(ValidationError {
436                        field: format!("finding[{}].location.path", i),
437                        value: path.to_string(),
438                        message: "location path must use forward slashes, not backslashes"
439                            .to_string(),
440                    });
441                }
442
443                // Must not start with `/` (relative paths only)
444                if path.starts_with('/') {
445                    errors.push(ValidationError {
446                        field: format!("finding[{}].location.path", i),
447                        value: path.to_string(),
448                        message:
449                            "location path must be relative, not absolute (cannot start with '/')"
450                                .to_string(),
451                    });
452                }
453
454                // Must not contain `..` (no parent directory traversal)
455                if path.contains("..") {
456                    errors.push(ValidationError {
457                        field: format!("finding[{}].location.path", i),
458                        value: path.to_string(),
459                        message: "location path must not contain '..' (parent directory traversal not allowed)".to_string(),
460                    });
461                }
462            }
463        }
464
465        if errors.is_empty() {
466            Ok(())
467        } else {
468            Err(errors)
469        }
470    }
471
472    /// Runs all validation checks on a receipt.
473    ///
474    /// # Arguments
475    /// * `receipt` - The receipt to validate
476    ///
477    /// # Returns
478    /// * `Ok(())` if all validations pass
479    /// * `Err(Vec<ValidationError>)` with all validation errors
480    pub fn validate_all(&self, receipt: &ReceiptEnvelope) -> Result<(), Vec<ValidationError>> {
481        let mut all_errors = Vec::new();
482
483        // Basic receipt validation
484        let receipt_result = self.validate_receipt(receipt);
485        if let Err(errors) = receipt_result.expect_valid() {
486            all_errors.extend(errors);
487        }
488
489        // Finding fields validation
490        let finding_result = self.validate_finding_fields(receipt);
491        if let Err(errors) = finding_result.expect_valid() {
492            all_errors.extend(errors);
493        }
494
495        // Check ID format validation
496        if let Err(errors) = self.validate_check_id_format(receipt) {
497            all_errors.extend(errors);
498        }
499
500        // Location paths validation
501        if let Err(errors) = self.validate_location_paths(receipt) {
502            all_errors.extend(errors);
503        }
504
505        if all_errors.is_empty() {
506            Ok(())
507        } else {
508            Err(all_errors)
509        }
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::AdapterError;
517    use buildfix_types::receipt::Finding;
518    use camino::Utf8PathBuf;
519    use std::path::Path;
520    use tempfile::TempDir;
521
522    struct DummyAdapter;
523
524    impl Adapter for DummyAdapter {
525        fn sensor_id(&self) -> &str {
526            "dummy"
527        }
528
529        fn load(&self, path: &Path) -> Result<ReceiptEnvelope, AdapterError> {
530            let content = std::fs::read_to_string(path)?;
531            serde_json::from_str(&content).map_err(AdapterError::Json)
532        }
533    }
534
535    #[test]
536    fn test_validation_empty_schema() {
537        let harness = AdapterTestHarness::new(DummyAdapter);
538        let receipt = ReceiptEnvelope {
539            schema: String::new(),
540            tool: buildfix_types::receipt::ToolInfo {
541                name: "test".to_string(),
542                version: None,
543                repo: None,
544                commit: None,
545            },
546            run: Default::default(),
547            verdict: Default::default(),
548            findings: vec![],
549            capabilities: None,
550            data: None,
551        };
552
553        let result = harness.validate_receipt(&receipt);
554        assert!(!result.is_valid());
555    }
556
557    #[test]
558    fn test_validation_empty_tool_name() {
559        let harness = AdapterTestHarness::new(DummyAdapter);
560        let receipt = ReceiptEnvelope {
561            schema: "sensor.report.v1".to_string(),
562            tool: buildfix_types::receipt::ToolInfo {
563                name: String::new(),
564                version: None,
565                repo: None,
566                commit: None,
567            },
568            run: Default::default(),
569            verdict: Default::default(),
570            findings: vec![],
571            capabilities: None,
572            data: None,
573        };
574
575        let result = harness.validate_receipt(&receipt);
576        assert!(!result.is_valid());
577    }
578
579    #[test]
580    fn test_validation_valid_receipt() {
581        let harness = AdapterTestHarness::new(DummyAdapter);
582        let receipt = ReceiptEnvelope {
583            schema: "sensor.report.v1".to_string(),
584            tool: buildfix_types::receipt::ToolInfo {
585                name: "test-tool".to_string(),
586                version: Some("1.0.0".to_string()),
587                repo: None,
588                commit: None,
589            },
590            run: Default::default(),
591            verdict: Default::default(),
592            findings: vec![],
593            capabilities: None,
594            data: None,
595        };
596
597        let result = harness.validate_receipt(&receipt);
598        assert!(result.is_valid());
599    }
600
601    #[test]
602    fn test_extract_check_ids() {
603        let harness = AdapterTestHarness::new(DummyAdapter);
604        let receipt = ReceiptEnvelope {
605            schema: "sensor.report.v1".to_string(),
606            tool: buildfix_types::receipt::ToolInfo {
607                name: "test".to_string(),
608                version: None,
609                repo: None,
610                commit: None,
611            },
612            run: Default::default(),
613            verdict: Default::default(),
614            findings: vec![
615                Finding {
616                    severity: Severity::Error,
617                    check_id: Some("DENY001".to_string()),
618                    code: None,
619                    message: Some("error".to_string()),
620                    location: None,
621                    fingerprint: None,
622                    data: None,
623                    ..Default::default()
624                },
625                Finding {
626                    severity: Severity::Warn,
627                    check_id: Some("WARN001".to_string()),
628                    code: None,
629                    message: Some("warning".to_string()),
630                    location: None,
631                    fingerprint: None,
632                    data: None,
633                    ..Default::default()
634                },
635                Finding {
636                    severity: Severity::Info,
637                    check_id: None,
638                    code: None,
639                    message: Some("info".to_string()),
640                    location: None,
641                    fingerprint: None,
642                    data: None,
643                    ..Default::default()
644                },
645            ],
646            capabilities: None,
647            data: None,
648        };
649
650        let ids = harness.extract_check_ids(&receipt);
651        assert!(ids.contains("DENY001"));
652        assert!(ids.contains("WARN001"));
653        assert_eq!(ids.len(), 2);
654    }
655
656    #[test]
657    fn test_golden_test_matching() {
658        let temp_dir = TempDir::new().unwrap();
659        let receipt_path = temp_dir.path().join("report.json");
660
661        let expected = ReceiptEnvelope {
662            schema: "sensor.report.v1".to_string(),
663            tool: buildfix_types::receipt::ToolInfo {
664                name: "test".to_string(),
665                version: Some("1.0.0".to_string()),
666                repo: None,
667                commit: None,
668            },
669            run: Default::default(),
670            verdict: Default::default(),
671            findings: vec![],
672            capabilities: None,
673            data: None,
674        };
675
676        let content = serde_json::to_string_pretty(&expected).unwrap();
677        std::fs::write(&receipt_path, content).unwrap();
678
679        let harness = AdapterTestHarness::new(DummyAdapter);
680        let result = harness.golden_test(&receipt_path, &expected);
681
682        assert!(result.is_ok());
683    }
684
685    #[test]
686    fn test_assert_finding_count() {
687        let harness = AdapterTestHarness::new(DummyAdapter);
688        let receipt = ReceiptEnvelope {
689            schema: "sensor.report.v1".to_string(),
690            tool: buildfix_types::receipt::ToolInfo {
691                name: "test".to_string(),
692                version: None,
693                repo: None,
694                commit: None,
695            },
696            run: Default::default(),
697            verdict: Default::default(),
698            findings: vec![
699                Finding {
700                    severity: Severity::Error,
701                    check_id: Some("ERR001".to_string()),
702                    code: None,
703                    message: Some("error1".to_string()),
704                    location: None,
705                    fingerprint: None,
706                    data: None,
707                    ..Default::default()
708                },
709                Finding {
710                    severity: Severity::Error,
711                    check_id: Some("ERR002".to_string()),
712                    code: None,
713                    message: Some("error2".to_string()),
714                    location: None,
715                    fingerprint: None,
716                    data: None,
717                    ..Default::default()
718                },
719                Finding {
720                    severity: Severity::Warn,
721                    check_id: Some("WARN001".to_string()),
722                    code: None,
723                    message: Some("warning".to_string()),
724                    location: None,
725                    fingerprint: None,
726                    data: None,
727                    ..Default::default()
728                },
729            ],
730            capabilities: None,
731            data: None,
732        };
733
734        let result = harness.assert_finding_count(&receipt, 3, None);
735        assert!(result.is_ok());
736
737        let result = harness.assert_finding_count(&receipt, 2, Some(Severity::Error));
738        assert!(result.is_ok());
739
740        let result = harness.assert_finding_count(&receipt, 1, Some(Severity::Warn));
741        assert!(result.is_ok());
742
743        let result = harness.assert_finding_count(&receipt, 5, None);
744        assert!(result.is_err());
745    }
746
747    // ==================== Check ID Format Validation Tests ====================
748
749    #[test]
750    fn test_validate_check_id_format_valid() {
751        let harness = AdapterTestHarness::new(DummyAdapter);
752        let receipt = ReceiptEnvelope {
753            schema: "sensor.report.v1".to_string(),
754            tool: buildfix_types::receipt::ToolInfo {
755                name: "test".to_string(),
756                version: None,
757                repo: None,
758                commit: None,
759            },
760            run: Default::default(),
761            verdict: Default::default(),
762            findings: vec![
763                Finding {
764                    severity: Severity::Error,
765                    check_id: Some("cargo-deny.ban.multiple-versions".to_string()),
766                    code: None,
767                    message: Some("error".to_string()),
768                    location: None,
769                    fingerprint: None,
770                    data: None,
771                    ..Default::default()
772                },
773                Finding {
774                    severity: Severity::Warn,
775                    check_id: Some("machete.unused_dependency.main".to_string()),
776                    code: None,
777                    message: Some("warning".to_string()),
778                    location: None,
779                    fingerprint: None,
780                    data: None,
781                    ..Default::default()
782                },
783            ],
784            capabilities: None,
785            data: None,
786        };
787
788        assert!(harness.validate_check_id_format(&receipt).is_ok());
789    }
790
791    #[test]
792    fn test_validate_check_id_format_no_dots() {
793        let harness = AdapterTestHarness::new(DummyAdapter);
794        let receipt = ReceiptEnvelope {
795            schema: "sensor.report.v1".to_string(),
796            tool: buildfix_types::receipt::ToolInfo {
797                name: "test".to_string(),
798                version: None,
799                repo: None,
800                commit: None,
801            },
802            run: Default::default(),
803            verdict: Default::default(),
804            findings: vec![Finding {
805                severity: Severity::Error,
806                check_id: Some("simplecheck".to_string()),
807                code: None,
808                message: Some("error".to_string()),
809                location: None,
810                fingerprint: None,
811                data: None,
812                ..Default::default()
813            }],
814            capabilities: None,
815            data: None,
816        };
817
818        let result = harness.validate_check_id_format(&receipt);
819        assert!(result.is_err());
820        let errors = result.unwrap_err();
821        assert_eq!(errors.len(), 1);
822        assert_eq!(errors[0].value, "simplecheck");
823    }
824
825    #[test]
826    fn test_validate_check_id_format_one_dot() {
827        let harness = AdapterTestHarness::new(DummyAdapter);
828        let receipt = ReceiptEnvelope {
829            schema: "sensor.report.v1".to_string(),
830            tool: buildfix_types::receipt::ToolInfo {
831                name: "test".to_string(),
832                version: None,
833                repo: None,
834                commit: None,
835            },
836            run: Default::default(),
837            verdict: Default::default(),
838            findings: vec![Finding {
839                severity: Severity::Error,
840                check_id: Some("sensor.check".to_string()),
841                code: None,
842                message: Some("error".to_string()),
843                location: None,
844                fingerprint: None,
845                data: None,
846                ..Default::default()
847            }],
848            capabilities: None,
849            data: None,
850        };
851
852        let result = harness.validate_check_id_format(&receipt);
853        assert!(result.is_err());
854        let errors = result.unwrap_err();
855        assert_eq!(errors.len(), 1);
856        assert_eq!(errors[0].value, "sensor.check");
857    }
858
859    #[test]
860    fn test_validate_check_id_format_uppercase() {
861        let harness = AdapterTestHarness::new(DummyAdapter);
862        let receipt = ReceiptEnvelope {
863            schema: "sensor.report.v1".to_string(),
864            tool: buildfix_types::receipt::ToolInfo {
865                name: "test".to_string(),
866                version: None,
867                repo: None,
868                commit: None,
869            },
870            run: Default::default(),
871            verdict: Default::default(),
872            findings: vec![Finding {
873                severity: Severity::Error,
874                check_id: Some("Cargo-Deny.Ban.Multiple".to_string()),
875                code: None,
876                message: Some("error".to_string()),
877                location: None,
878                fingerprint: None,
879                data: None,
880                ..Default::default()
881            }],
882            capabilities: None,
883            data: None,
884        };
885
886        let result = harness.validate_check_id_format(&receipt);
887        assert!(result.is_err());
888        let errors = result.unwrap_err();
889        assert_eq!(errors.len(), 1);
890        assert_eq!(errors[0].value, "Cargo-Deny.Ban.Multiple");
891    }
892
893    #[test]
894    fn test_validate_check_id_format_none() {
895        let harness = AdapterTestHarness::new(DummyAdapter);
896        let receipt = ReceiptEnvelope {
897            schema: "sensor.report.v1".to_string(),
898            tool: buildfix_types::receipt::ToolInfo {
899                name: "test".to_string(),
900                version: None,
901                repo: None,
902                commit: None,
903            },
904            run: Default::default(),
905            verdict: Default::default(),
906            findings: vec![Finding {
907                severity: Severity::Info,
908                check_id: None,
909                code: None,
910                message: Some("info".to_string()),
911                location: None,
912                fingerprint: None,
913                data: None,
914                ..Default::default()
915            }],
916            capabilities: None,
917            data: None,
918        };
919
920        // No check_id means no validation error for check_id format
921        assert!(harness.validate_check_id_format(&receipt).is_ok());
922    }
923
924    // ==================== Location Path Validation Tests ====================
925
926    #[test]
927    fn test_validate_location_paths_valid() {
928        let harness = AdapterTestHarness::new(DummyAdapter);
929        let receipt = ReceiptEnvelope {
930            schema: "sensor.report.v1".to_string(),
931            tool: buildfix_types::receipt::ToolInfo {
932                name: "test".to_string(),
933                version: None,
934                repo: None,
935                commit: None,
936            },
937            run: Default::default(),
938            verdict: Default::default(),
939            findings: vec![
940                Finding {
941                    severity: Severity::Error,
942                    check_id: Some("test.check.id".to_string()),
943                    code: None,
944                    message: Some("error".to_string()),
945                    location: Some(buildfix_types::receipt::Location {
946                        path: Utf8PathBuf::from("src/main.rs"),
947                        line: Some(1),
948                        column: None,
949                    }),
950                    fingerprint: None,
951                    data: None,
952                    ..Default::default()
953                },
954                Finding {
955                    severity: Severity::Warn,
956                    check_id: Some("test.check.two".to_string()),
957                    code: None,
958                    message: Some("warning".to_string()),
959                    location: Some(buildfix_types::receipt::Location {
960                        path: Utf8PathBuf::from("crates/domain/src/lib.rs"),
961                        line: None,
962                        column: None,
963                    }),
964                    fingerprint: None,
965                    data: None,
966                    ..Default::default()
967                },
968            ],
969            capabilities: None,
970            data: None,
971        };
972
973        assert!(harness.validate_location_paths(&receipt).is_ok());
974    }
975
976    #[test]
977    fn test_validate_location_paths_empty() {
978        let harness = AdapterTestHarness::new(DummyAdapter);
979        let receipt = ReceiptEnvelope {
980            schema: "sensor.report.v1".to_string(),
981            tool: buildfix_types::receipt::ToolInfo {
982                name: "test".to_string(),
983                version: None,
984                repo: None,
985                commit: None,
986            },
987            run: Default::default(),
988            verdict: Default::default(),
989            findings: vec![Finding {
990                severity: Severity::Error,
991                check_id: Some("test.check.id".to_string()),
992                code: None,
993                message: Some("error".to_string()),
994                location: Some(buildfix_types::receipt::Location {
995                    path: Utf8PathBuf::new(),
996                    line: None,
997                    column: None,
998                }),
999                fingerprint: None,
1000                data: None,
1001                ..Default::default()
1002            }],
1003            capabilities: None,
1004            data: None,
1005        };
1006
1007        let result = harness.validate_location_paths(&receipt);
1008        assert!(result.is_err());
1009        let errors = result.unwrap_err();
1010        assert_eq!(errors.len(), 1);
1011        assert!(errors[0].message.contains("empty"));
1012    }
1013
1014    #[test]
1015    fn test_validate_location_paths_backslash() {
1016        let harness = AdapterTestHarness::new(DummyAdapter);
1017        let receipt = ReceiptEnvelope {
1018            schema: "sensor.report.v1".to_string(),
1019            tool: buildfix_types::receipt::ToolInfo {
1020                name: "test".to_string(),
1021                version: None,
1022                repo: None,
1023                commit: None,
1024            },
1025            run: Default::default(),
1026            verdict: Default::default(),
1027            findings: vec![Finding {
1028                severity: Severity::Error,
1029                check_id: Some("test.check.id".to_string()),
1030                code: None,
1031                message: Some("error".to_string()),
1032                location: Some(buildfix_types::receipt::Location {
1033                    path: Utf8PathBuf::from("src\\main.rs"),
1034                    line: None,
1035                    column: None,
1036                }),
1037                fingerprint: None,
1038                data: None,
1039                ..Default::default()
1040            }],
1041            capabilities: None,
1042            data: None,
1043        };
1044
1045        let result = harness.validate_location_paths(&receipt);
1046        assert!(result.is_err());
1047        let errors = result.unwrap_err();
1048        assert_eq!(errors.len(), 1);
1049        assert!(errors[0].message.contains("forward slashes"));
1050    }
1051
1052    #[test]
1053    fn test_validate_location_paths_absolute() {
1054        let harness = AdapterTestHarness::new(DummyAdapter);
1055        let receipt = ReceiptEnvelope {
1056            schema: "sensor.report.v1".to_string(),
1057            tool: buildfix_types::receipt::ToolInfo {
1058                name: "test".to_string(),
1059                version: None,
1060                repo: None,
1061                commit: None,
1062            },
1063            run: Default::default(),
1064            verdict: Default::default(),
1065            findings: vec![Finding {
1066                severity: Severity::Error,
1067                check_id: Some("test.check.id".to_string()),
1068                code: None,
1069                message: Some("error".to_string()),
1070                location: Some(buildfix_types::receipt::Location {
1071                    path: Utf8PathBuf::from("/src/main.rs"),
1072                    line: None,
1073                    column: None,
1074                }),
1075                fingerprint: None,
1076                data: None,
1077                ..Default::default()
1078            }],
1079            capabilities: None,
1080            data: None,
1081        };
1082
1083        let result = harness.validate_location_paths(&receipt);
1084        assert!(result.is_err());
1085        let errors = result.unwrap_err();
1086        assert_eq!(errors.len(), 1);
1087        assert!(errors[0].message.contains("relative"));
1088    }
1089
1090    #[test]
1091    fn test_validate_location_paths_parent_traversal() {
1092        let harness = AdapterTestHarness::new(DummyAdapter);
1093        let receipt = ReceiptEnvelope {
1094            schema: "sensor.report.v1".to_string(),
1095            tool: buildfix_types::receipt::ToolInfo {
1096                name: "test".to_string(),
1097                version: None,
1098                repo: None,
1099                commit: None,
1100            },
1101            run: Default::default(),
1102            verdict: Default::default(),
1103            findings: vec![Finding {
1104                severity: Severity::Error,
1105                check_id: Some("test.check.id".to_string()),
1106                code: None,
1107                message: Some("error".to_string()),
1108                location: Some(buildfix_types::receipt::Location {
1109                    path: Utf8PathBuf::from("../src/main.rs"),
1110                    line: None,
1111                    column: None,
1112                }),
1113                fingerprint: None,
1114                data: None,
1115                ..Default::default()
1116            }],
1117            capabilities: None,
1118            data: None,
1119        };
1120
1121        let result = harness.validate_location_paths(&receipt);
1122        assert!(result.is_err());
1123        let errors = result.unwrap_err();
1124        assert_eq!(errors.len(), 1);
1125        assert!(errors[0].message.contains(".."));
1126    }
1127
1128    #[test]
1129    fn test_validate_location_paths_no_location() {
1130        let harness = AdapterTestHarness::new(DummyAdapter);
1131        let receipt = ReceiptEnvelope {
1132            schema: "sensor.report.v1".to_string(),
1133            tool: buildfix_types::receipt::ToolInfo {
1134                name: "test".to_string(),
1135                version: None,
1136                repo: None,
1137                commit: None,
1138            },
1139            run: Default::default(),
1140            verdict: Default::default(),
1141            findings: vec![Finding {
1142                severity: Severity::Info,
1143                check_id: Some("test.check.id".to_string()),
1144                code: None,
1145                message: Some("info".to_string()),
1146                location: None,
1147                fingerprint: None,
1148                data: None,
1149                ..Default::default()
1150            }],
1151            capabilities: None,
1152            data: None,
1153        };
1154
1155        // No location means no path validation error
1156        assert!(harness.validate_location_paths(&receipt).is_ok());
1157    }
1158
1159    // ==================== Validate All Tests ====================
1160
1161    #[test]
1162    fn test_validate_all_valid() {
1163        let harness = AdapterTestHarness::new(DummyAdapter);
1164        let receipt = ReceiptEnvelope {
1165            schema: "sensor.report.v1".to_string(),
1166            tool: buildfix_types::receipt::ToolInfo {
1167                name: "test".to_string(),
1168                version: Some("1.0.0".to_string()),
1169                repo: None,
1170                commit: None,
1171            },
1172            run: Default::default(),
1173            verdict: Default::default(),
1174            findings: vec![Finding {
1175                severity: Severity::Error,
1176                check_id: Some("cargo-deny.ban.multiple-versions".to_string()),
1177                code: None,
1178                message: Some("error".to_string()),
1179                location: Some(buildfix_types::receipt::Location {
1180                    path: Utf8PathBuf::from("Cargo.toml"),
1181                    line: None,
1182                    column: None,
1183                }),
1184                fingerprint: None,
1185                data: None,
1186                ..Default::default()
1187            }],
1188            capabilities: None,
1189            data: None,
1190        };
1191
1192        assert!(harness.validate_all(&receipt).is_ok());
1193    }
1194
1195    #[test]
1196    fn test_validate_all_multiple_errors() {
1197        let harness = AdapterTestHarness::new(DummyAdapter);
1198        let receipt = ReceiptEnvelope {
1199            schema: String::new(), // Invalid: empty schema
1200            tool: buildfix_types::receipt::ToolInfo {
1201                name: String::new(), // Invalid: empty name
1202                version: None,
1203                repo: None,
1204                commit: None,
1205            },
1206            run: Default::default(),
1207            verdict: Default::default(),
1208            findings: vec![Finding {
1209                severity: Severity::Error,
1210                check_id: Some("INVALID".to_string()), // Invalid: not enough dots
1211                code: None,
1212                message: Some("error".to_string()),
1213                location: Some(buildfix_types::receipt::Location {
1214                    path: Utf8PathBuf::from("/absolute/path.rs"), // Invalid: absolute path
1215                    line: None,
1216                    column: None,
1217                }),
1218                fingerprint: None,
1219                data: None,
1220                ..Default::default()
1221            }],
1222            capabilities: None,
1223            data: None,
1224        };
1225
1226        let result = harness.validate_all(&receipt);
1227        assert!(result.is_err());
1228        let errors = result.unwrap_err();
1229        // Should have multiple errors from different validators
1230        assert!(errors.len() >= 3);
1231    }
1232
1233    // ==================== ValidationError Tests ====================
1234
1235    #[test]
1236    fn test_validation_error_display() {
1237        let error = ValidationError {
1238            field: "test.field".to_string(),
1239            value: "invalid_value".to_string(),
1240            message: "Field is invalid".to_string(),
1241        };
1242
1243        let display = format!("{}", error);
1244        assert!(display.contains("test.field"));
1245        assert!(display.contains("invalid_value"));
1246        assert!(display.contains("Field is invalid"));
1247    }
1248}