diff --git a/crates/auths-cli/src/commands/doctor.rs b/crates/auths-cli/src/commands/doctor.rs index a022b4c9..941aa1d9 100644 --- a/crates/auths-cli/src/commands/doctor.rs +++ b/crates/auths-cli/src/commands/doctor.rs @@ -9,6 +9,7 @@ use auths_sdk::ports::diagnostics::{ CheckCategory, CheckResult, ConfigIssue, DiagnosticFix, FixApplied, }; use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow; +use chrono::{DateTime, Utc}; use clap::Parser; use serde::Serialize; use std::io::IsTerminal; @@ -147,7 +148,9 @@ fn compute_exit_code(checks: &[Check]) -> i32 { } /// Run all prerequisite checks. +#[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() injected here fn run_checks() -> Vec { + let now = Utc::now(); let adapter = PosixDiagnosticAdapter; let workflow = DiagnosticsWorkflow::new(&adapter, &adapter); @@ -155,13 +158,6 @@ fn run_checks() -> Vec { if let Ok(report) = workflow.run() { for cr in report.checks { - // Categorize SDK checks: system tools are Advisory, git signing is Critical - let category = if cr.name == "Git signing config" { - CheckCategory::Critical - } else { - CheckCategory::Advisory - }; - let suggestion = if cr.passed { None } else { @@ -172,16 +168,20 @@ fn run_checks() -> Vec { passed: cr.passed, detail: format_check_detail(&cr), suggestion, - category, + category: cr.category, }); } } // Domain checks are all Critical checks.push(check_keychain_accessible()); - checks.push(check_identity_exists()); + checks.push(check_auths_repo()); + checks.push(check_identity_valid(now)); checks.push(check_allowed_signers_file()); + // Advisory: network connectivity + checks.push(check_registry_connectivity()); + checks } @@ -303,9 +303,19 @@ fn suggestion_for_check(name: &str) -> Option { "Git installed" => { Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string()) } + "Git version" => Some( + "Upgrade Git to 2.34.0+ for SSH signing: https://git-scm.com/downloads".to_string(), + ), + "Git user identity" => Some( + "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(), + ), "ssh-keygen installed" => Some("Install OpenSSH for your platform.".to_string()), "Git signing config" => Some("Run: auths doctor --fix".to_string()), + "Auths directory" => Some("Run: auths init --profile developer".to_string()), "Allowed signers file" => Some("Run: auths doctor --fix".to_string()), + "Registry connectivity" => { + Some("Check your internet connection or try again later.".to_string()) + } _ => None, } } @@ -332,7 +342,46 @@ fn check_keychain_accessible() -> Check { } } -fn check_identity_exists() -> Check { +fn check_auths_repo() -> Check { + let (passed, detail, suggestion) = match auths_core::paths::auths_home() { + Ok(path) => { + if !path.exists() { + ( + false, + format!("{} (not found)", path.display()), + Some("Run: auths init --profile developer".to_string()), + ) + } else { + match crate::factories::storage::open_git_repo(&path) { + Ok(_) => ( + true, + format!("{} (valid git repository)", path.display()), + None, + ), + Err(_) => ( + false, + format!("{} (exists but not a valid git repo)", path.display()), + Some("Run: auths init --profile developer".to_string()), + ), + } + } + } + Err(e) => ( + false, + format!("Cannot resolve path: {e}"), + Some("Run: auths init --profile developer".to_string()), + ), + }; + Check { + name: "Auths directory".to_string(), + passed, + detail, + suggestion, + category: CheckCategory::Critical, + } +} + +fn check_identity_valid(now: DateTime) -> Check { let (passed, detail, suggestion) = match keychain::get_platform_keychain() { Ok(keychain) => match keychain.list_aliases() { Ok(aliases) if aliases.is_empty() => ( @@ -340,7 +389,23 @@ fn check_identity_exists() -> Check { "No keys found in keychain".to_string(), Some("Run: auths init --profile developer (or: auths id init)".to_string()), ), - Ok(aliases) => (true, format!("{} key(s) found", aliases.len()), None), + Ok(aliases) => { + let key_count = aliases.len(); + let expiry_info = check_attestation_expiry(now); + match expiry_info { + ExpiryStatus::AllExpired(msg) => ( + false, + format!("{key_count} key(s) found, but {msg}"), + Some("Run: auths device refresh".to_string()), + ), + ExpiryStatus::ExpiringSoon(msg) => { + (true, format!("{key_count} key(s) found ({msg})"), None) + } + ExpiryStatus::Ok | ExpiryStatus::NoAttestations => { + (true, format!("{key_count} key(s) found"), None) + } + } + } Err(e) => ( false, format!("Failed to list keys: {e}"), @@ -362,6 +427,67 @@ fn check_identity_exists() -> Check { } } +enum ExpiryStatus { + Ok, + NoAttestations, + ExpiringSoon(String), + AllExpired(String), +} + +fn check_attestation_expiry(now: DateTime) -> ExpiryStatus { + use auths_id::storage::attestation::AttestationSource; + use auths_storage::git::RegistryAttestationStorage; + + let repo_path = match auths_core::paths::auths_home() { + Ok(p) if p.exists() => p, + _ => return ExpiryStatus::NoAttestations, + }; + + let storage = RegistryAttestationStorage::new(&repo_path); + let attestations = match storage.load_all_attestations() { + Ok(a) => a, + Err(_) => return ExpiryStatus::NoAttestations, + }; + + if attestations.is_empty() { + return ExpiryStatus::NoAttestations; + } + + let active: Vec<_> = attestations + .iter() + .filter(|a| a.revoked_at.is_none()) + .collect(); + + if active.is_empty() { + return ExpiryStatus::AllExpired("all attestations revoked".to_string()); + } + + let with_expiry: Vec<_> = active.iter().filter(|a| a.expires_at.is_some()).collect(); + + if with_expiry.is_empty() { + return ExpiryStatus::Ok; + } + + let all_expired = with_expiry + .iter() + .all(|a| a.expires_at.is_some_and(|exp| exp < now)); + + if all_expired { + return ExpiryStatus::AllExpired("all attestations expired".to_string()); + } + + let warn_threshold = now + chrono::Duration::days(7); + let expiring_soon = with_expiry + .iter() + .any(|a| a.expires_at.is_some_and(|exp| exp < warn_threshold)); + + if expiring_soon { + return ExpiryStatus::ExpiringSoon("some attestations expiring within 7 days".to_string()); + } + + ExpiryStatus::Ok +} + fn check_allowed_signers_file() -> Check { use auths_sdk::workflows::allowed_signers::{AllowedSigners, SignerSource}; @@ -434,6 +560,41 @@ fn check_allowed_signers_file() -> Check { } } +fn check_registry_connectivity() -> Check { + use auths_sdk::registration::DEFAULT_REGISTRY_URL; + + let url = format!("{DEFAULT_REGISTRY_URL}/health"); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build(); + + let (passed, detail) = match client { + Ok(client) => match client.get(&url).send() { + Ok(resp) if resp.status().is_success() => { + (true, format!("{DEFAULT_REGISTRY_URL} (reachable)")) + } + Ok(resp) => ( + false, + format!("{DEFAULT_REGISTRY_URL} (HTTP {})", resp.status()), + ), + Err(e) => (false, format!("unreachable: {e}")), + }, + Err(e) => (false, format!("HTTP client error: {e}")), + }; + + Check { + name: "Registry connectivity".to_string(), + passed, + detail, + suggestion: if passed { + None + } else { + suggestion_for_check("Registry connectivity") + }, + category: CheckCategory::Advisory, + } +} + /// Print the report in human-readable format. fn print_report(report: &DoctorReport) { let out = Output::new(); @@ -502,16 +663,25 @@ mod tests { } #[test] - fn test_git_signing_config_checks_all_five_configs() { + fn test_workflow_includes_version_and_user_checks() { use super::*; let adapter = PosixDiagnosticAdapter; let workflow = DiagnosticsWorkflow::new(&adapter, &adapter); let report = workflow.run().unwrap(); - let signing_check = report - .checks - .iter() - .find(|c| c.name == "Git signing config"); - assert!(signing_check.is_some(), "signing config check must exist"); + + let check_names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect(); + assert!( + check_names.contains(&"Git signing config"), + "signing config check must exist" + ); + assert!( + check_names.contains(&"Git version"), + "git version check must exist" + ); + assert!( + check_names.contains(&"Git user identity"), + "git user identity check must exist" + ); } #[test] @@ -526,4 +696,13 @@ mod tests { assert!(text.starts_with("Run:"), "bad suggestion: {}", text); } } + + #[test] + fn test_suggestion_for_all_new_checks() { + use super::suggestion_for_check; + assert!(suggestion_for_check("Git version").is_some()); + assert!(suggestion_for_check("Git user identity").is_some()); + assert!(suggestion_for_check("Auths directory").is_some()); + assert!(suggestion_for_check("Registry connectivity").is_some()); + } } diff --git a/crates/auths-cli/src/commands/init/helpers.rs b/crates/auths-cli/src/commands/init/helpers.rs index 76c1c6ef..06fec689 100644 --- a/crates/auths-cli/src/commands/init/helpers.rs +++ b/crates/auths-cli/src/commands/init/helpers.rs @@ -7,12 +7,11 @@ use std::path::{Path, PathBuf}; use std::process::Command; use auths_sdk::workflows::allowed_signers::AllowedSigners; +use auths_sdk::workflows::diagnostics::{MIN_GIT_VERSION, parse_git_version}; use auths_storage::git::RegistryAttestationStorage; use crate::ux::format::Output; -pub(crate) const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0); - pub(crate) fn get_auths_repo_path() -> Result { auths_core::paths::auths_home().map_err(|e| anyhow!(e)) } @@ -28,7 +27,7 @@ pub(crate) fn check_git_version(out: &Output) -> Result<()> { } let version_str = String::from_utf8_lossy(&output.stdout); - let version = parse_git_version(&version_str)?; + let version = cli_parse_git_version(&version_str)?; if version < MIN_GIT_VERSION { return Err(anyhow!( @@ -49,31 +48,9 @@ pub(crate) fn check_git_version(out: &Output) -> Result<()> { Ok(()) } -pub(crate) fn parse_git_version(version_str: &str) -> Result<(u32, u32, u32)> { - let parts: Vec<&str> = version_str.split_whitespace().collect(); - let version_part = parts - .iter() - .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit())) - .ok_or_else(|| anyhow!("Could not parse Git version from: {}", version_str))?; - - let numbers: Vec = version_part - .split('.') - .take(3) - .filter_map(|s| { - s.chars() - .take_while(|c| c.is_ascii_digit()) - .collect::() - .parse() - .ok() - }) - .collect(); - - match numbers.as_slice() { - [major, minor, patch, ..] => Ok((*major, *minor, *patch)), - [major, minor] => Ok((*major, *minor, 0)), - [major] => Ok((*major, 0, 0)), - _ => Err(anyhow!("Could not parse Git version: {}", version_str)), - } +pub(crate) fn cli_parse_git_version(version_str: &str) -> Result<(u32, u32, u32)> { + parse_git_version(version_str) + .ok_or_else(|| anyhow!("Could not parse Git version from: {}", version_str)) } #[allow(clippy::disallowed_methods)] // CLI boundary: CI env detection @@ -470,13 +447,13 @@ mod tests { #[test] fn test_parse_git_version() { - assert_eq!(parse_git_version("git version 2.39.0").unwrap(), (2, 39, 0)); - assert_eq!(parse_git_version("git version 2.34.1").unwrap(), (2, 34, 1)); + assert_eq!(parse_git_version("git version 2.39.0"), Some((2, 39, 0))); + assert_eq!(parse_git_version("git version 2.34.1"), Some((2, 34, 1))); assert_eq!( - parse_git_version("git version 2.39.0.windows.1").unwrap(), - (2, 39, 0) + parse_git_version("git version 2.39.0.windows.1"), + Some((2, 39, 0)) ); - assert_eq!(parse_git_version("git version 2.30").unwrap(), (2, 30, 0)); + assert_eq!(parse_git_version("git version 2.30"), Some((2, 30, 0))); } #[test] diff --git a/crates/auths-sdk/src/domains/diagnostics/service.rs b/crates/auths-sdk/src/domains/diagnostics/service.rs index a1dc0ec9..f2a89707 100644 --- a/crates/auths-sdk/src/domains/diagnostics/service.rs +++ b/crates/auths-sdk/src/domains/diagnostics/service.rs @@ -5,6 +5,44 @@ use crate::ports::diagnostics::{ DiagnosticReport, GitDiagnosticProvider, }; +/// Minimum Git version required for SSH signing support. +pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0); + +/// Parses a Git version string into a `(major, minor, patch)` tuple. +/// +/// Args: +/// * `version_str`: Raw output from `git --version`, e.g. `"git version 2.39.0"`. +/// +/// Usage: +/// ```ignore +/// let v = parse_git_version("git version 2.39.0"); +/// assert_eq!(v, Some((2, 39, 0))); +/// ``` +pub fn parse_git_version(version_str: &str) -> Option<(u32, u32, u32)> { + let version_part = version_str + .split_whitespace() + .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))?; + + let numbers: Vec = version_part + .split('.') + .take(3) + .filter_map(|s| { + s.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok() + }) + .collect(); + + match numbers.as_slice() { + [major, minor, patch, ..] => Some((*major, *minor, *patch)), + [major, minor] => Some((*major, *minor, 0)), + [major] => Some((*major, 0, 0)), + _ => None, + } +} + /// Orchestrates diagnostic checks without subprocess calls. /// /// Args: @@ -29,7 +67,13 @@ impl DiagnosticsWorkflow< /// Names of all available checks. pub fn available_checks() -> &'static [&'static str] { - &["git_version", "ssh_keygen", "git_signing_config"] + &[ + "git_version", + "git_version_minimum", + "ssh_keygen", + "git_signing_config", + "git_user_config", + ] } /// Run a single diagnostic check by name. @@ -38,6 +82,15 @@ impl DiagnosticsWorkflow< pub fn run_single(&self, name: &str) -> Result { match name { "git_version" => self.git.check_git_version(), + "git_version_minimum" => { + let git_check = self.git.check_git_version()?; + let mut checks = Vec::new(); + self.check_git_version_minimum(&git_check, &mut checks); + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } "ssh_keygen" => self.crypto.check_ssh_keygen_available(), "git_signing_config" => { let mut checks = Vec::new(); @@ -47,6 +100,14 @@ impl DiagnosticsWorkflow< .next() .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) } + "git_user_config" => { + let mut checks = Vec::new(); + self.check_git_user_config(&mut checks)?; + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } _ => Err(DiagnosticError::CheckNotFound(name.to_string())), } } @@ -61,14 +122,104 @@ impl DiagnosticsWorkflow< pub fn run(&self) -> Result { let mut checks = Vec::new(); - checks.push(self.git.check_git_version()?); + let git_check = self.git.check_git_version()?; + self.check_git_version_minimum(&git_check, &mut checks); + checks.push(git_check); + checks.push(self.crypto.check_ssh_keygen_available()?); + self.check_git_user_config(&mut checks)?; self.check_git_signing_config(&mut checks)?; Ok(DiagnosticReport { checks }) } + fn check_git_version_minimum(&self, git_check: &CheckResult, checks: &mut Vec) { + let version_str = git_check.message.as_deref().unwrap_or(""); + match parse_git_version(version_str) { + Some(version) if version >= MIN_GIT_VERSION => { + checks.push(CheckResult { + name: "Git version".to_string(), + passed: true, + message: Some(format!( + "{}.{}.{} (>= {}.{}.{})", + version.0, + version.1, + version.2, + MIN_GIT_VERSION.0, + MIN_GIT_VERSION.1, + MIN_GIT_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Critical, + }); + } + Some(version) => { + checks.push(CheckResult { + name: "Git version".to_string(), + passed: false, + message: Some(format!( + "{}.{}.{} found, need >= {}.{}.{} for SSH signing", + version.0, + version.1, + version.2, + MIN_GIT_VERSION.0, + MIN_GIT_VERSION.1, + MIN_GIT_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Critical, + }); + } + None => { + if !git_check.passed { + return; + } + checks.push(CheckResult { + name: "Git version".to_string(), + passed: false, + message: Some(format!("Could not parse version from: {version_str}")), + config_issues: vec![], + category: CheckCategory::Advisory, + }); + } + } + } + + fn check_git_user_config(&self, checks: &mut Vec) -> Result<(), DiagnosticError> { + let name = self.git.get_git_config("user.name")?; + let email = self.git.get_git_config("user.email")?; + + let mut issues: Vec = Vec::new(); + if name.is_none() { + issues.push(ConfigIssue::Absent("user.name".to_string())); + } + if email.is_none() { + issues.push(ConfigIssue::Absent("user.email".to_string())); + } + + let passed = issues.is_empty(); + let message = if passed { + Some(format!( + "{} <{}>", + name.unwrap_or_default(), + email.unwrap_or_default() + )) + } else { + None + }; + + checks.push(CheckResult { + name: "Git user identity".to_string(), + passed, + message, + config_issues: issues, + category: CheckCategory::Advisory, + }); + + Ok(()) + } + fn check_git_signing_config( &self, checks: &mut Vec, diff --git a/crates/auths-sdk/src/testing/fakes/diagnostics.rs b/crates/auths-sdk/src/testing/fakes/diagnostics.rs index 0fd8a0b4..29346b36 100644 --- a/crates/auths-sdk/src/testing/fakes/diagnostics.rs +++ b/crates/auths-sdk/src/testing/fakes/diagnostics.rs @@ -5,6 +5,7 @@ use crate::ports::diagnostics::{ /// Configurable fake for [`GitDiagnosticProvider`]. pub struct FakeGitDiagnosticProvider { version_passes: bool, + version_string: String, configs: Vec<(String, Option)>, } @@ -17,12 +18,22 @@ impl FakeGitDiagnosticProvider { pub fn new(version_passes: bool, configs: Vec<(&str, Option<&str>)>) -> Self { Self { version_passes, + version_string: "git version 2.40.0".to_string(), configs: configs .into_iter() .map(|(k, v)| (k.to_string(), v.map(str::to_string))) .collect(), } } + + /// Override the version string returned by `check_git_version`. + /// + /// Args: + /// * `version`: Raw version string, e.g. `"git version 2.30.0"`. + pub fn with_version_string(mut self, version: &str) -> Self { + self.version_string = version.to_string(); + self + } } impl GitDiagnosticProvider for FakeGitDiagnosticProvider { @@ -31,7 +42,7 @@ impl GitDiagnosticProvider for FakeGitDiagnosticProvider { name: "Git installed".to_string(), passed: self.version_passes, message: if self.version_passes { - Some("git version 2.40.0".to_string()) + Some(self.version_string.clone()) } else { Some("git not found".to_string()) }, diff --git a/crates/auths-sdk/src/workflows/diagnostics.rs b/crates/auths-sdk/src/workflows/diagnostics.rs index a1dc0ec9..f2a89707 100644 --- a/crates/auths-sdk/src/workflows/diagnostics.rs +++ b/crates/auths-sdk/src/workflows/diagnostics.rs @@ -5,6 +5,44 @@ use crate::ports::diagnostics::{ DiagnosticReport, GitDiagnosticProvider, }; +/// Minimum Git version required for SSH signing support. +pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0); + +/// Parses a Git version string into a `(major, minor, patch)` tuple. +/// +/// Args: +/// * `version_str`: Raw output from `git --version`, e.g. `"git version 2.39.0"`. +/// +/// Usage: +/// ```ignore +/// let v = parse_git_version("git version 2.39.0"); +/// assert_eq!(v, Some((2, 39, 0))); +/// ``` +pub fn parse_git_version(version_str: &str) -> Option<(u32, u32, u32)> { + let version_part = version_str + .split_whitespace() + .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))?; + + let numbers: Vec = version_part + .split('.') + .take(3) + .filter_map(|s| { + s.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok() + }) + .collect(); + + match numbers.as_slice() { + [major, minor, patch, ..] => Some((*major, *minor, *patch)), + [major, minor] => Some((*major, *minor, 0)), + [major] => Some((*major, 0, 0)), + _ => None, + } +} + /// Orchestrates diagnostic checks without subprocess calls. /// /// Args: @@ -29,7 +67,13 @@ impl DiagnosticsWorkflow< /// Names of all available checks. pub fn available_checks() -> &'static [&'static str] { - &["git_version", "ssh_keygen", "git_signing_config"] + &[ + "git_version", + "git_version_minimum", + "ssh_keygen", + "git_signing_config", + "git_user_config", + ] } /// Run a single diagnostic check by name. @@ -38,6 +82,15 @@ impl DiagnosticsWorkflow< pub fn run_single(&self, name: &str) -> Result { match name { "git_version" => self.git.check_git_version(), + "git_version_minimum" => { + let git_check = self.git.check_git_version()?; + let mut checks = Vec::new(); + self.check_git_version_minimum(&git_check, &mut checks); + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } "ssh_keygen" => self.crypto.check_ssh_keygen_available(), "git_signing_config" => { let mut checks = Vec::new(); @@ -47,6 +100,14 @@ impl DiagnosticsWorkflow< .next() .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) } + "git_user_config" => { + let mut checks = Vec::new(); + self.check_git_user_config(&mut checks)?; + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } _ => Err(DiagnosticError::CheckNotFound(name.to_string())), } } @@ -61,14 +122,104 @@ impl DiagnosticsWorkflow< pub fn run(&self) -> Result { let mut checks = Vec::new(); - checks.push(self.git.check_git_version()?); + let git_check = self.git.check_git_version()?; + self.check_git_version_minimum(&git_check, &mut checks); + checks.push(git_check); + checks.push(self.crypto.check_ssh_keygen_available()?); + self.check_git_user_config(&mut checks)?; self.check_git_signing_config(&mut checks)?; Ok(DiagnosticReport { checks }) } + fn check_git_version_minimum(&self, git_check: &CheckResult, checks: &mut Vec) { + let version_str = git_check.message.as_deref().unwrap_or(""); + match parse_git_version(version_str) { + Some(version) if version >= MIN_GIT_VERSION => { + checks.push(CheckResult { + name: "Git version".to_string(), + passed: true, + message: Some(format!( + "{}.{}.{} (>= {}.{}.{})", + version.0, + version.1, + version.2, + MIN_GIT_VERSION.0, + MIN_GIT_VERSION.1, + MIN_GIT_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Critical, + }); + } + Some(version) => { + checks.push(CheckResult { + name: "Git version".to_string(), + passed: false, + message: Some(format!( + "{}.{}.{} found, need >= {}.{}.{} for SSH signing", + version.0, + version.1, + version.2, + MIN_GIT_VERSION.0, + MIN_GIT_VERSION.1, + MIN_GIT_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Critical, + }); + } + None => { + if !git_check.passed { + return; + } + checks.push(CheckResult { + name: "Git version".to_string(), + passed: false, + message: Some(format!("Could not parse version from: {version_str}")), + config_issues: vec![], + category: CheckCategory::Advisory, + }); + } + } + } + + fn check_git_user_config(&self, checks: &mut Vec) -> Result<(), DiagnosticError> { + let name = self.git.get_git_config("user.name")?; + let email = self.git.get_git_config("user.email")?; + + let mut issues: Vec = Vec::new(); + if name.is_none() { + issues.push(ConfigIssue::Absent("user.name".to_string())); + } + if email.is_none() { + issues.push(ConfigIssue::Absent("user.email".to_string())); + } + + let passed = issues.is_empty(); + let message = if passed { + Some(format!( + "{} <{}>", + name.unwrap_or_default(), + email.unwrap_or_default() + )) + } else { + None + }; + + checks.push(CheckResult { + name: "Git user identity".to_string(), + passed, + message, + config_issues: issues, + category: CheckCategory::Advisory, + }); + + Ok(()) + } + fn check_git_signing_config( &self, checks: &mut Vec, diff --git a/crates/auths-sdk/tests/cases/diagnostics.rs b/crates/auths-sdk/tests/cases/diagnostics.rs index d15b3437..73c94b8b 100644 --- a/crates/auths-sdk/tests/cases/diagnostics.rs +++ b/crates/auths-sdk/tests/cases/diagnostics.rs @@ -1,5 +1,5 @@ use auths_sdk::testing::fakes::{FakeCryptoDiagnosticProvider, FakeGitDiagnosticProvider}; -use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow; +use auths_sdk::workflows::diagnostics::{DiagnosticsWorkflow, parse_git_version}; #[test] fn test_diagnostics_all_pass() { @@ -11,6 +11,8 @@ fn test_diagnostics_all_pass() { ("tag.gpgsign", Some("true")), ("user.signingkey", Some("auths:main")), ("gpg.ssh.program", Some("auths-sign")), + ("user.name", Some("Test User")), + ("user.email", Some("test@example.com")), ], ); let crypto = FakeCryptoDiagnosticProvider::new(true); @@ -95,3 +97,117 @@ fn test_diagnostics_git_config_missing() { signing_check.config_issues ); } + +#[test] +fn test_diagnostics_git_version_too_low() { + let git = + FakeGitDiagnosticProvider::new(true, vec![]).with_version_string("git version 2.30.0"); + let crypto = FakeCryptoDiagnosticProvider::new(true); + + let workflow = DiagnosticsWorkflow::new(&git, &crypto); + let report = workflow.run().unwrap(); + + let version_check = report + .checks + .iter() + .find(|c| c.name == "Git version") + .expect("Git version check must exist"); + assert!( + !version_check.passed, + "version 2.30.0 should fail minimum check" + ); + assert!( + version_check + .message + .as_deref() + .unwrap_or("") + .contains("2.30.0"), + "message should contain the detected version" + ); +} + +#[test] +fn test_diagnostics_git_version_sufficient() { + let git = + FakeGitDiagnosticProvider::new(true, vec![]).with_version_string("git version 2.40.0"); + let crypto = FakeCryptoDiagnosticProvider::new(true); + + let workflow = DiagnosticsWorkflow::new(&git, &crypto); + let report = workflow.run().unwrap(); + + let version_check = report + .checks + .iter() + .find(|c| c.name == "Git version") + .expect("Git version check must exist"); + assert!( + version_check.passed, + "version 2.40.0 should pass minimum check" + ); +} + +#[test] +fn test_diagnostics_git_user_config_missing() { + let git = FakeGitDiagnosticProvider::new(true, vec![]); + let crypto = FakeCryptoDiagnosticProvider::new(true); + + let workflow = DiagnosticsWorkflow::new(&git, &crypto); + let report = workflow.run().unwrap(); + + let user_check = report + .checks + .iter() + .find(|c| c.name == "Git user identity") + .expect("Git user identity check must exist"); + assert!(!user_check.passed, "missing user.name/email should fail"); + assert!( + user_check + .config_issues + .iter() + .any(|i| matches!(i, auths_sdk::ports::diagnostics::ConfigIssue::Absent(k) if k == "user.name")), + "expected Absent(user.name), got: {:?}", + user_check.config_issues + ); +} + +#[test] +fn test_diagnostics_git_user_config_present() { + let git = FakeGitDiagnosticProvider::new( + true, + vec![ + ("user.name", Some("Test User")), + ("user.email", Some("test@example.com")), + ], + ); + let crypto = FakeCryptoDiagnosticProvider::new(true); + + let workflow = DiagnosticsWorkflow::new(&git, &crypto); + let report = workflow.run().unwrap(); + + let user_check = report + .checks + .iter() + .find(|c| c.name == "Git user identity") + .expect("Git user identity check must exist"); + assert!(user_check.passed, "present user.name/email should pass"); + assert!( + user_check + .message + .as_deref() + .unwrap_or("") + .contains("Test User"), + "message should contain the user name" + ); +} + +#[test] +fn test_parse_git_version_various_formats() { + assert_eq!(parse_git_version("git version 2.39.0"), Some((2, 39, 0))); + assert_eq!(parse_git_version("git version 2.34.1"), Some((2, 34, 1))); + assert_eq!( + parse_git_version("git version 2.39.0.windows.1"), + Some((2, 39, 0)) + ); + assert_eq!(parse_git_version("git version 2.30"), Some((2, 30, 0))); + assert_eq!(parse_git_version("no version here"), None); +}