Skip to main content

libverify_core/controls/
required_status_checks.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{CheckConclusion, EvidenceBundle, EvidenceState};
3
4/// Verifies that CI check runs on the change request HEAD commit all passed.
5pub struct RequiredStatusChecksControl;
6
7impl Control for RequiredStatusChecksControl {
8    fn id(&self) -> ControlId {
9        builtin::id(builtin::REQUIRED_STATUS_CHECKS)
10    }
11
12    fn description(&self) -> &'static str {
13        "At least one required status check must be configured"
14    }
15
16    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
17        let id = self.id();
18
19        let runs = match &evidence.check_runs {
20            EvidenceState::NotApplicable => {
21                return vec![ControlFinding::not_applicable(
22                    id,
23                    "Check runs evidence is not applicable",
24                )];
25            }
26            EvidenceState::Missing { gaps } => {
27                return vec![ControlFinding::indeterminate(
28                    id,
29                    "Check runs evidence is unavailable",
30                    vec!["commit".to_string()],
31                    gaps.clone(),
32                )];
33            }
34            EvidenceState::Complete { value } => value,
35            EvidenceState::Partial { value, .. } => value,
36        };
37
38        if runs.is_empty() {
39            return vec![ControlFinding::indeterminate(
40                id,
41                "No check runs found on the HEAD commit",
42                vec!["commit".to_string()],
43                vec![],
44            )];
45        }
46
47        let failed: Vec<&str> = runs
48            .iter()
49            .filter(|r| is_failing_conclusion(&r.conclusion))
50            .map(|r| r.name.as_str())
51            .collect();
52
53        if failed.is_empty() {
54            vec![ControlFinding::satisfied(
55                id,
56                format!("{} check run(s) passed", runs.len()),
57                vec!["commit".to_string()],
58            )]
59        } else {
60            vec![ControlFinding::violated(
61                id,
62                format!(
63                    "{} check run(s) failed: {}",
64                    failed.len(),
65                    failed.join(", ")
66                ),
67                vec!["commit".to_string()],
68            )]
69        }
70    }
71}
72
73/// Returns true if the conclusion represents a failing state.
74fn is_failing_conclusion(conclusion: &CheckConclusion) -> bool {
75    matches!(
76        conclusion,
77        CheckConclusion::Failure
78            | CheckConclusion::Cancelled
79            | CheckConclusion::TimedOut
80            | CheckConclusion::ActionRequired
81            | CheckConclusion::Pending
82    )
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::control::ControlStatus;
89    use crate::evidence::{CheckConclusion, CheckRunEvidence, EvidenceGap};
90
91    fn make_bundle(runs: Vec<CheckRunEvidence>) -> EvidenceBundle {
92        EvidenceBundle {
93            check_runs: EvidenceState::complete(runs),
94            ..Default::default()
95        }
96    }
97
98    fn run(name: &str, conclusion: CheckConclusion) -> CheckRunEvidence {
99        CheckRunEvidence {
100            name: name.to_string(),
101            conclusion,
102            app_slug: None,
103        }
104    }
105
106    // --- Satisfied ---
107
108    #[test]
109    fn all_checks_success_is_satisfied() {
110        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
111            "ci/build",
112            CheckConclusion::Success,
113        )]));
114        assert_eq!(findings.len(), 1);
115        assert_eq!(findings[0].status, ControlStatus::Satisfied);
116        assert_eq!(findings[0].subjects, vec!["commit"]);
117        assert!(findings[0].rationale.contains("1 check run(s) passed"));
118    }
119
120    #[test]
121    fn multiple_checks_all_pass_is_satisfied() {
122        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
123            run("ci/build", CheckConclusion::Success),
124            run("ci/test", CheckConclusion::Success),
125            run("ci/lint", CheckConclusion::Neutral),
126        ]));
127        assert_eq!(findings.len(), 1);
128        assert_eq!(findings[0].status, ControlStatus::Satisfied);
129        assert!(findings[0].rationale.contains("3 check run(s) passed"));
130    }
131
132    #[test]
133    fn skipped_check_is_satisfied() {
134        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
135            run("ci/build", CheckConclusion::Success),
136            run("ci/optional", CheckConclusion::Skipped),
137        ]));
138        assert_eq!(findings[0].status, ControlStatus::Satisfied);
139    }
140
141    // --- Violated ---
142
143    #[test]
144    fn one_check_failed_is_violated() {
145        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
146            run("ci/build", CheckConclusion::Success),
147            run("ci/test", CheckConclusion::Failure),
148        ]));
149        assert_eq!(findings.len(), 1);
150        assert_eq!(findings[0].status, ControlStatus::Violated);
151        assert!(findings[0].rationale.contains("ci/test"));
152    }
153
154    #[test]
155    fn cancelled_check_is_violated() {
156        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
157            "ci/build",
158            CheckConclusion::Cancelled,
159        )]));
160        assert_eq!(findings[0].status, ControlStatus::Violated);
161    }
162
163    #[test]
164    fn timed_out_check_is_violated() {
165        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
166            "ci/build",
167            CheckConclusion::TimedOut,
168        )]));
169        assert_eq!(findings[0].status, ControlStatus::Violated);
170    }
171
172    #[test]
173    fn pending_check_is_violated() {
174        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
175            "ci/deploy",
176            CheckConclusion::Pending,
177        )]));
178        assert_eq!(findings[0].status, ControlStatus::Violated);
179    }
180
181    // --- Indeterminate ---
182
183    #[test]
184    fn no_check_runs_is_indeterminate() {
185        let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![]));
186        assert_eq!(findings.len(), 1);
187        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
188        assert!(findings[0].rationale.contains("No check runs found"));
189    }
190
191    #[test]
192    fn indeterminate_when_evidence_missing() {
193        let bundle = EvidenceBundle {
194            check_runs: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
195                source: "github".to_string(),
196                subject: "commit".to_string(),
197                detail: "403 Forbidden".to_string(),
198            }]),
199            ..Default::default()
200        };
201        let findings = RequiredStatusChecksControl.evaluate(&bundle);
202        assert_eq!(findings.len(), 1);
203        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
204        assert_eq!(findings[0].evidence_gaps.len(), 1);
205    }
206
207    // --- NotApplicable ---
208
209    #[test]
210    fn not_applicable_when_evidence_not_applicable() {
211        let bundle = EvidenceBundle {
212            check_runs: EvidenceState::not_applicable(),
213            ..Default::default()
214        };
215        let findings = RequiredStatusChecksControl.evaluate(&bundle);
216        assert_eq!(findings.len(), 1);
217        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
218    }
219
220    // --- Edge cases ---
221
222    #[test]
223    fn partial_evidence_still_evaluates() {
224        let bundle = EvidenceBundle {
225            check_runs: EvidenceState::partial(
226                vec![run("ci/test", CheckConclusion::Success)],
227                vec![EvidenceGap::Truncated {
228                    source: "github".to_string(),
229                    subject: "check_runs".to_string(),
230                }],
231            ),
232            ..Default::default()
233        };
234        let findings = RequiredStatusChecksControl.evaluate(&bundle);
235        assert_eq!(findings.len(), 1);
236        assert_eq!(findings[0].status, ControlStatus::Satisfied);
237    }
238
239    #[test]
240    fn control_id_is_required_status_checks() {
241        assert_eq!(
242            RequiredStatusChecksControl.id(),
243            builtin::id(builtin::REQUIRED_STATUS_CHECKS)
244        );
245    }
246}