use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use secrecy::SecretString;
use crate::errors::{CfgdError, FileError, SecretError};
use crate::output::Printer;
use crate::providers::{
FileAction, FileDiff, FileLayer, FileTree, SecretBackend, SecretProvider, SystemConfigurator,
SystemDrift,
};
pub struct MockFileManager {
pub scan_source_calls: Mutex<Vec<String>>,
pub scan_target_calls: Mutex<Vec<String>>,
pub diff_calls: Mutex<Vec<String>>,
pub apply_calls: Mutex<Vec<String>>,
pub fail_apply: Mutex<bool>,
}
impl MockFileManager {
pub fn new() -> Self {
Self {
scan_source_calls: Mutex::new(Vec::new()),
scan_target_calls: Mutex::new(Vec::new()),
diff_calls: Mutex::new(Vec::new()),
apply_calls: Mutex::new(Vec::new()),
fail_apply: Mutex::new(false),
}
}
pub fn set_fail_apply(&self, fail: bool) {
*self.fail_apply.lock().unwrap() = fail;
}
}
impl Default for MockFileManager {
fn default() -> Self {
Self::new()
}
}
impl crate::providers::FileManager for MockFileManager {
fn scan_source(&self, layers: &[FileLayer]) -> crate::errors::Result<FileTree> {
let names: Vec<String> = layers.iter().map(|l| l.origin_source.clone()).collect();
self.scan_source_calls.lock().unwrap().push(names.join(","));
Ok(FileTree {
files: BTreeMap::new(),
})
}
fn scan_target(&self, paths: &[PathBuf]) -> crate::errors::Result<FileTree> {
let names: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
self.scan_target_calls.lock().unwrap().push(names.join(","));
Ok(FileTree {
files: BTreeMap::new(),
})
}
fn diff(&self, _source: &FileTree, _target: &FileTree) -> crate::errors::Result<Vec<FileDiff>> {
self.diff_calls.lock().unwrap().push("diff".into());
Ok(Vec::new())
}
fn apply(&self, actions: &[FileAction], _printer: &Printer) -> crate::errors::Result<()> {
self.apply_calls
.lock()
.unwrap()
.push(format!("{} actions", actions.len()));
if *self.fail_apply.lock().unwrap() {
return Err(CfgdError::File(FileError::SourceNotFound {
path: PathBuf::from("mock-failure"),
}));
}
Ok(())
}
}
pub struct MockSecretBackend {
pub backend_name: String,
pub available: bool,
pub decrypt_calls: Mutex<Vec<PathBuf>>,
pub encrypt_calls: Mutex<Vec<PathBuf>>,
pub edit_calls: Mutex<Vec<PathBuf>>,
pub decrypt_result: Mutex<Option<String>>,
pub fail_decrypt: Mutex<bool>,
}
impl MockSecretBackend {
pub fn new(name: &str) -> Self {
Self {
backend_name: name.to_string(),
available: true,
decrypt_calls: Mutex::new(Vec::new()),
encrypt_calls: Mutex::new(Vec::new()),
edit_calls: Mutex::new(Vec::new()),
decrypt_result: Mutex::new(Some("mock-secret-value".into())),
fail_decrypt: Mutex::new(false),
}
}
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
pub fn with_decrypt_result(self, value: &str) -> Self {
*self.decrypt_result.lock().unwrap() = Some(value.to_string());
self
}
pub fn set_fail_decrypt(&self, fail: bool) {
*self.fail_decrypt.lock().unwrap() = fail;
}
}
impl SecretBackend for MockSecretBackend {
fn name(&self) -> &str {
&self.backend_name
}
fn is_available(&self) -> bool {
self.available
}
fn encrypt_file(&self, path: &Path) -> crate::errors::Result<()> {
self.encrypt_calls.lock().unwrap().push(path.to_path_buf());
Ok(())
}
fn decrypt_file(&self, path: &Path) -> crate::errors::Result<SecretString> {
self.decrypt_calls.lock().unwrap().push(path.to_path_buf());
if *self.fail_decrypt.lock().unwrap() {
return Err(CfgdError::Secret(SecretError::DecryptionFailed {
path: path.to_path_buf(),
message: "mock decrypt failure".into(),
}));
}
let value = self
.decrypt_result
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Ok(SecretString::from(value))
}
fn edit_file(&self, path: &Path) -> crate::errors::Result<()> {
self.edit_calls.lock().unwrap().push(path.to_path_buf());
Ok(())
}
}
pub struct MockSecretProvider {
pub provider_name: String,
pub available: bool,
pub resolve_calls: Mutex<Vec<String>>,
pub resolve_result: Mutex<Option<String>>,
pub fail_resolve: Mutex<bool>,
}
impl MockSecretProvider {
pub fn new(name: &str) -> Self {
Self {
provider_name: name.to_string(),
available: true,
resolve_calls: Mutex::new(Vec::new()),
resolve_result: Mutex::new(Some("mock-resolved-secret".into())),
fail_resolve: Mutex::new(false),
}
}
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
pub fn with_resolve_result(self, value: &str) -> Self {
*self.resolve_result.lock().unwrap() = Some(value.to_string());
self
}
pub fn set_fail_resolve(&self, fail: bool) {
*self.fail_resolve.lock().unwrap() = fail;
}
}
impl SecretProvider for MockSecretProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn is_available(&self) -> bool {
self.available
}
fn resolve(&self, reference: &str) -> crate::errors::Result<SecretString> {
self.resolve_calls
.lock()
.unwrap()
.push(reference.to_string());
if *self.fail_resolve.lock().unwrap() {
return Err(CfgdError::Secret(SecretError::UnresolvableRef {
reference: reference.to_string(),
}));
}
let value = self
.resolve_result
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Ok(SecretString::from(value))
}
}
pub struct MockSystemConfigurator {
pub configurator_name: String,
pub available: bool,
pub apply_calls: Mutex<Vec<serde_yaml::Value>>,
pub drift: Mutex<Vec<SystemDrift>>,
pub fail_apply: Mutex<bool>,
pub fail_diff: Mutex<bool>,
}
impl MockSystemConfigurator {
pub fn new(name: &str) -> Self {
Self {
configurator_name: name.to_string(),
available: true,
apply_calls: Mutex::new(Vec::new()),
drift: Mutex::new(Vec::new()),
fail_apply: Mutex::new(false),
fail_diff: Mutex::new(false),
}
}
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
pub fn with_drift(self, drifts: Vec<SystemDrift>) -> Self {
*self.drift.lock().unwrap() = drifts;
self
}
pub fn failing(self) -> Self {
*self.fail_diff.lock().unwrap() = true;
self
}
pub fn set_fail_apply(&self, fail: bool) {
*self.fail_apply.lock().unwrap() = fail;
}
}
impl SystemConfigurator for MockSystemConfigurator {
fn name(&self) -> &str {
&self.configurator_name
}
fn is_available(&self) -> bool {
self.available
}
fn current_state(&self) -> crate::errors::Result<serde_yaml::Value> {
Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()))
}
fn diff(&self, _desired: &serde_yaml::Value) -> crate::errors::Result<Vec<SystemDrift>> {
if *self.fail_diff.lock().unwrap() {
return Err(CfgdError::Io(std::io::Error::other("mock diff failed")));
}
let items = self.drift.lock().unwrap();
Ok(items
.iter()
.map(|d| SystemDrift {
key: d.key.clone(),
expected: d.expected.clone(),
actual: d.actual.clone(),
})
.collect())
}
fn apply(&self, desired: &serde_yaml::Value, _printer: &Printer) -> crate::errors::Result<()> {
self.apply_calls.lock().unwrap().push(desired.clone());
if *self.fail_apply.lock().unwrap() {
return Err(CfgdError::Io(std::io::Error::other(
"mock system apply failure",
)));
}
Ok(())
}
}
pub struct TestEnvBuilder {
dir: Option<tempfile::TempDir>,
configs: Vec<(String, String)>,
profiles: Vec<(String, String)>,
modules: Vec<(String, String)>,
files: Vec<(String, String)>,
}
impl TestEnvBuilder {
pub fn new() -> Self {
Self {
dir: None,
configs: Vec::new(),
profiles: Vec::new(),
modules: Vec::new(),
files: Vec::new(),
}
}
pub fn config(mut self, name: &str, content: &str) -> Self {
self.configs.push((name.to_string(), content.to_string()));
self
}
pub fn profile(mut self, name: &str, content: &str) -> Self {
self.profiles.push((name.to_string(), content.to_string()));
self
}
pub fn module(mut self, name: &str, content: &str) -> Self {
self.modules.push((name.to_string(), content.to_string()));
self
}
pub fn file(mut self, path: &str, content: &str) -> Self {
self.files.push((path.to_string(), content.to_string()));
self
}
pub fn build(mut self) -> TestEnv {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let root = dir.path().to_path_buf();
let config_dir = root.join("config");
let profiles_dir = root.join("profiles");
let modules_dir = root.join("modules");
let state_dir = root.join("state");
std::fs::create_dir_all(&config_dir).expect("create config dir");
std::fs::create_dir_all(&profiles_dir).expect("create profiles dir");
std::fs::create_dir_all(&modules_dir).expect("create modules dir");
std::fs::create_dir_all(&state_dir).expect("create state dir");
for (name, content) in &self.configs {
let path = config_dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create config subdirs");
}
std::fs::write(&path, content).expect("write config file");
}
for (name, content) in &self.profiles {
let path = profiles_dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create profile subdirs");
}
std::fs::write(&path, content).expect("write profile file");
}
for (name, content) in &self.modules {
let path = modules_dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create module subdirs");
}
std::fs::write(&path, content).expect("write module file");
}
for (rel_path, content) in &self.files {
let path = root.join(rel_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create file subdirs");
}
std::fs::write(&path, content).expect("write file");
}
let home_guard = crate::with_test_home_guard(&root);
self.dir = Some(dir);
TestEnv {
_home_guard: home_guard,
_dir: self.dir.take().unwrap(),
root,
config_dir,
profiles_dir,
modules_dir,
state_dir,
}
}
}
impl Default for TestEnvBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct TestEnv {
_home_guard: crate::TestHomeGuard,
_dir: tempfile::TempDir,
pub root: PathBuf,
pub config_dir: PathBuf,
pub profiles_dir: PathBuf,
pub modules_dir: PathBuf,
pub state_dir: PathBuf,
}
impl TestEnv {
pub fn write_file(&self, rel_path: &str, content: &str) {
let path = self.root.join(rel_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&path, content).expect("write file");
}
pub fn read_at(&self, rel_path: &str) -> String {
std::fs::read_to_string(self.root.join(rel_path)).expect("read file")
}
pub fn file_exists(&self, rel_path: &str) -> bool {
self.root.join(rel_path).exists()
}
pub fn path(&self, rel_path: &str) -> PathBuf {
self.root.join(rel_path)
}
}
pub fn file_url(path: &Path) -> String {
crate::to_file_url(path)
}
pub fn assert_snapshot_golden(base: &Path, name: &str, actual: &str) {
let path = base.join(name);
if std::env::var("INSTA_UPDATE").as_deref() == Ok("always") || !path.exists() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create snapshot parent dir");
}
std::fs::write(&path, actual).expect("write snapshot golden");
return;
}
let expected = std::fs::read_to_string(&path).expect("read snapshot golden");
let actual_norm = crate::normalize_line_endings(actual);
let expected_norm = crate::normalize_line_endings(&expected);
pretty_assertions::assert_eq!(actual_norm, expected_norm, "snapshot mismatch: {name}");
}
pub fn init_test_git_repo(dir: &Path) {
std::fs::create_dir_all(dir).expect("create git repo dir");
let repo = git2::Repository::init(dir).expect("git init");
let mut config = repo.config().expect("repo config");
config
.set_str("user.name", "cfgd-test")
.expect("set user.name");
config
.set_str("user.email", "test@cfgd.io")
.expect("set user.email");
let readme_path = dir.join("README");
std::fs::write(&readme_path, "test repo\n").expect("write README");
let mut index = repo.index().expect("repo index");
index
.add_path(Path::new("README"))
.expect("add README to index");
index.write().expect("write index");
let tree_id = index.write_tree().expect("write tree");
let tree = repo.find_tree(tree_id).expect("find tree");
let sig = repo.signature().expect("signature");
repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
.expect("initial commit");
}
struct BareGitCommitSpec {
message: String,
files: Vec<(String, String)>,
}
struct BareGitBranchSpec {
name: String,
files: Vec<(String, String)>,
}
pub struct BareGitRepoBuilder {
commits: Vec<BareGitCommitSpec>,
tags: Vec<(String, usize)>,
branches: Vec<BareGitBranchSpec>,
}
impl BareGitRepoBuilder {
fn new() -> Self {
Self {
commits: Vec::new(),
tags: Vec::new(),
branches: Vec::new(),
}
}
pub fn commit(mut self, message: &str, files: &[(&str, &str)]) -> Self {
self.commits.push(BareGitCommitSpec {
message: message.to_string(),
files: files
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect(),
});
self
}
pub fn tag(mut self, name: &str) -> Self {
assert!(
!self.commits.is_empty(),
"BareGitRepoBuilder::tag() requires at least one prior commit"
);
self.tags.push((name.to_string(), self.commits.len() - 1));
self
}
pub fn branch(mut self, name: &str, files: &[(&str, &str)]) -> Self {
self.branches.push(BareGitBranchSpec {
name: name.to_string(),
files: files
.iter()
.map(|(p, c)| (p.to_string(), c.to_string()))
.collect(),
});
self
}
pub fn build(self) -> BareGitRepo {
assert!(
!self.commits.is_empty(),
"BareGitRepoBuilder requires at least one commit"
);
let bare_dir = tempfile::TempDir::new().expect("create bare repo tempdir");
let work_dir = tempfile::TempDir::new().expect("create working clone tempdir");
let bare_repo = git2::Repository::init_bare(bare_dir.path()).expect("git init --bare");
let work_path = work_dir.path().join("work");
let work_repo = git2::Repository::init(&work_path).expect("git init work clone");
let mut config = work_repo.config().expect("repo config");
config
.set_str("user.name", "cfgd-test")
.expect("set user.name");
config
.set_str("user.email", "test@cfgd.io")
.expect("set user.email");
let bare_url = file_url(bare_dir.path());
work_repo
.remote("origin", &bare_url)
.expect("add origin remote");
let sig = git2::Signature::now("cfgd-test", "test@cfgd.io").expect("signature");
let mut commit_oids: Vec<git2::Oid> = Vec::new();
for spec in &self.commits {
for (path, content) in &spec.files {
let full_path = work_path.join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&full_path, content).expect("write file");
}
let mut index = work_repo.index().expect("repo index");
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.expect("add all to index");
index.write().expect("write index");
let tree_id = index.write_tree().expect("write tree");
let tree = work_repo.find_tree(tree_id).expect("find tree");
let parents: Vec<git2::Commit<'_>> = if commit_oids.is_empty() {
vec![]
} else {
let last_oid = *commit_oids.last().expect("last oid");
vec![work_repo.find_commit(last_oid).expect("find parent commit")]
};
let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
let oid = work_repo
.commit(Some("HEAD"), &sig, &sig, &spec.message, &tree, &parent_refs)
.expect("commit");
commit_oids.push(oid);
}
let head_branch = work_repo
.head()
.expect("HEAD")
.shorthand()
.unwrap_or("master")
.to_string();
let mut remote = work_repo.find_remote("origin").expect("find origin remote");
remote
.push(
&[&format!(
"refs/heads/{head_branch}:refs/heads/{head_branch}"
)],
None,
)
.expect("push main branch to bare");
for (tag_name, commit_idx) in &self.tags {
let oid = commit_oids[*commit_idx];
let obj = bare_repo
.find_object(oid, None)
.expect("find tagged object in bare");
bare_repo
.tag_lightweight(tag_name, &obj, false)
.expect("create tag in bare");
}
for branch_spec in &self.branches {
let head_commit = work_repo
.head()
.expect("HEAD")
.peel_to_commit()
.expect("peel HEAD to commit");
work_repo
.branch(&branch_spec.name, &head_commit, false)
.expect("create branch");
work_repo
.set_head(&format!("refs/heads/{}", branch_spec.name))
.expect("set HEAD to branch");
work_repo
.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.expect("checkout branch");
for (path, content) in &branch_spec.files {
let full_path = work_path.join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&full_path, content).expect("write file");
}
let mut index = work_repo.index().expect("repo index");
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.expect("add all to index");
index.write().expect("write index");
let tree_id = index.write_tree().expect("write tree");
let tree = work_repo.find_tree(tree_id).expect("find tree");
let branch_head = work_repo
.head()
.expect("HEAD")
.peel_to_commit()
.expect("peel HEAD");
work_repo
.commit(
Some("HEAD"),
&sig,
&sig,
&format!("branch commit: {}", branch_spec.name),
&tree,
&[&branch_head],
)
.expect("commit on branch");
remote
.push(
&[&format!(
"refs/heads/{}:refs/heads/{}",
branch_spec.name, branch_spec.name
)],
None,
)
.expect("push branch to bare");
work_repo
.set_head(&format!("refs/heads/{head_branch}"))
.expect("set HEAD back to main");
work_repo
.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.expect("checkout main");
}
BareGitRepo {
_bare_dir: bare_dir,
_work_dir: work_dir,
bare_repo,
head_branch,
}
}
}
pub struct BareGitRepo {
_bare_dir: tempfile::TempDir,
_work_dir: tempfile::TempDir,
bare_repo: git2::Repository,
head_branch: String,
}
impl BareGitRepo {
pub fn builder() -> BareGitRepoBuilder {
BareGitRepoBuilder::new()
}
pub fn url(&self) -> String {
file_url(self.bare_repo.path())
}
pub fn path(&self) -> &Path {
self.bare_repo.path()
}
pub fn head_branch(&self) -> &str {
&self.head_branch
}
pub fn has_tag(&self, name: &str) -> bool {
self.bare_repo
.find_reference(&format!("refs/tags/{name}"))
.is_ok()
}
pub fn has_branch(&self, name: &str) -> bool {
self.bare_repo
.find_reference(&format!("refs/heads/{name}"))
.is_ok()
}
pub fn tags(&self) -> Vec<String> {
self.bare_repo
.tag_names(None)
.map(|names| names.iter().flatten().map(|s| s.to_string()).collect())
.unwrap_or_default()
}
}
pub fn test_printer() -> crate::output::Printer {
crate::output::Printer::new(crate::output::Verbosity::Quiet)
}
pub struct NoopDaemonHooks;
impl crate::daemon::DaemonHooks for NoopDaemonHooks {
fn build_registry(&self, _: &crate::config::CfgdConfig) -> crate::providers::ProviderRegistry {
crate::providers::ProviderRegistry::new()
}
fn plan_files(
&self,
_: &std::path::Path,
_: &crate::config::ResolvedProfile,
) -> crate::errors::Result<Vec<crate::providers::FileAction>> {
Ok(vec![])
}
fn plan_packages(
&self,
_: &crate::config::MergedProfile,
_: &[&dyn crate::providers::PackageManager],
) -> crate::errors::Result<Vec<crate::providers::PackageAction>> {
Ok(vec![])
}
fn extend_registry_custom_managers(
&self,
_: &mut crate::providers::ProviderRegistry,
_: &crate::config::PackagesSpec,
) {
}
fn expand_tilde(&self, path: &std::path::Path) -> PathBuf {
crate::expand_tilde(path)
}
}
pub use crate::config::FileStrategy as TestFileStrategy;
pub fn linux_ubuntu_platform() -> crate::platform::Platform {
crate::platform::Platform {
os: crate::platform::Os::Linux,
distro: crate::platform::Distro::Ubuntu,
version: "22.04".into(),
arch: crate::platform::Arch::X86_64,
}
}
pub fn macos_platform() -> crate::platform::Platform {
crate::platform::Platform {
os: crate::platform::Os::MacOS,
distro: crate::platform::Distro::MacOS,
version: "14.0".into(),
arch: crate::platform::Arch::Aarch64,
}
}
pub fn make_empty_resolved() -> crate::config::ResolvedProfile {
crate::config::ResolvedProfile {
layers: vec![crate::config::ProfileLayer {
source: "local".to_string(),
profile_name: "test".to_string(),
priority: 1000,
policy: crate::config::LayerPolicy::Local,
spec: crate::config::ProfileSpec::default(),
}],
merged: crate::config::MergedProfile::default(),
}
}
pub fn test_state() -> crate::state::StateStore {
crate::state::StateStore::open_in_memory().expect("open in-memory state store")
}
pub fn make_resolved_module(name: &str) -> crate::modules::ResolvedModule {
crate::modules::ResolvedModule {
name: name.to_string(),
packages: vec![
crate::modules::ResolvedPackage {
canonical_name: "neovim".to_string(),
resolved_name: "neovim".to_string(),
manager: "brew".to_string(),
version: Some("0.10.2".to_string()),
script: None,
},
crate::modules::ResolvedPackage {
canonical_name: "ripgrep".to_string(),
resolved_name: "ripgrep".to_string(),
manager: "brew".to_string(),
version: Some("14.1.0".to_string()),
script: None,
},
],
files: vec![],
env: vec![],
aliases: vec![],
post_apply_scripts: vec![],
pre_apply_scripts: Vec::new(),
pre_reconcile_scripts: Vec::new(),
post_reconcile_scripts: Vec::new(),
on_change_scripts: Vec::new(),
system: std::collections::HashMap::new(),
depends: vec![],
dir: PathBuf::from("."),
}
}
pub fn make_test_modules(
specs: &[(&str, &[&str])],
) -> std::collections::HashMap<String, crate::modules::LoadedModule> {
let mut modules = std::collections::HashMap::new();
for (name, deps) in specs {
modules.insert(
name.to_string(),
crate::modules::LoadedModule {
name: name.to_string(),
spec: crate::config::ModuleSpec {
depends: deps.iter().map(|s| s.to_string()).collect(),
..Default::default()
},
dir: PathBuf::from(format!("/fake/{name}")),
},
);
}
modules
}
pub fn make_manager_map<'a>(
entries: &[(&str, &'a dyn crate::providers::PackageManager)],
) -> std::collections::HashMap<String, &'a dyn crate::providers::PackageManager> {
entries
.iter()
.map(|(name, mgr)| (name.to_string(), *mgr))
.collect()
}
pub const SAMPLE_CONFIG_YAML: &str = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test-config
spec:
profile: default
origin:
type: Git
url: https://github.com/test/repo.git
branch: master
"#;
pub const SAMPLE_CONFIG_NO_ORIGIN_YAML: &str = r#"
apiVersion: cfgd.io/v1alpha1
kind: Config
metadata:
name: test-config
spec:
profile: default
"#;
pub const SAMPLE_PROFILE_YAML: &str = r#"
apiVersion: cfgd.io/v1alpha1
kind: Profile
metadata:
name: base
spec:
env:
- name: editor
value: vim
- name: shell
value: /bin/zsh
packages:
brew:
formulae:
- ripgrep
- fd
cargo:
- bat
"#;
pub const SAMPLE_MODULE_YAML: &str = r#"
apiVersion: cfgd.io/v1alpha1
kind: Module
metadata:
name: nvim
spec:
depends: [node]
packages:
- name: neovim
minVersion: "0.9"
prefer: [brew, snap, apt]
aliases:
snap: nvim
- name: ripgrep
files:
- source: config/
target: ~/.config/nvim/
"#;
#[cfg(unix)]
pub struct ToolShim {
_tmp: tempfile::TempDir,
env_var: String,
log_path: std::path::PathBuf,
}
#[cfg(unix)]
impl ToolShim {
pub fn install(env_var: &str, exit_code: i32, stdout: &str, stderr: &str) -> Self {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("tempdir");
let bin_path = tmp.path().join(format!("shim-{env_var}"));
let log_path = tmp.path().join("argv.log");
let stdout_lit = stdout.replace('\'', "'\\''");
let stderr_lit = stderr.replace('\'', "'\\''");
let script = format!(
"#!/bin/sh\n\
printf '%s\\n' \"$*\" >> \"$CFGD_TOOL_SHIM_LOG\"\n\
printf '%s' '{stdout_lit}'\n\
printf '%s' '{stderr_lit}' 1>&2\n\
exit {exit_code}\n",
);
std::fs::write(&bin_path, script).expect("write shim");
let mut perms = std::fs::metadata(&bin_path).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin_path, perms).expect("chmod");
unsafe {
std::env::set_var(env_var, &bin_path);
std::env::set_var("CFGD_TOOL_SHIM_LOG", &log_path);
}
Self {
_tmp: tmp,
env_var: env_var.to_string(),
log_path,
}
}
pub fn argv_log(&self) -> String {
std::fs::read_to_string(&self.log_path).unwrap_or_default()
}
pub fn invocation_count(&self) -> usize {
self.argv_log().lines().filter(|l| !l.is_empty()).count()
}
}
#[cfg(unix)]
impl Drop for ToolShim {
fn drop(&mut self) {
unsafe {
std::env::remove_var(&self.env_var);
std::env::remove_var("CFGD_TOOL_SHIM_LOG");
}
}
}
#[cfg(unix)]
pub fn install_named_path_shim(
binary: &str,
exit_code: u8,
stdout: &str,
stderr: &str,
) -> (tempfile::TempDir, EnvVarGuard) {
use std::os::unix::fs::PermissionsExt;
let bin_dir = tempfile::tempdir().expect("tempdir");
let script = format!(
"#!/bin/sh\nprintf '%s' \"{}\"\nprintf '%s' \"{}\" >&2\nexit {}\n",
stdout.replace('"', "\\\""),
stderr.replace('"', "\\\""),
exit_code
);
let path = bin_dir.path().join(binary);
std::fs::write(&path, script).expect("write shim");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let old_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", bin_dir.path().display(), old_path);
let path_guard = EnvVarGuard::set("PATH", &new_path);
(bin_dir, path_guard)
}
#[cfg(unix)]
pub fn install_named_path_shims(shims: &[(&str, i32)]) -> (tempfile::TempDir, EnvVarGuard) {
use std::os::unix::fs::PermissionsExt;
let bin_dir = tempfile::tempdir().expect("tempdir");
for (name, exit_code) in shims {
let path = bin_dir.path().join(name);
std::fs::write(&path, format!("#!/bin/sh\nexit {exit_code}\n")).expect("write shim");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
}
let old_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", bin_dir.path().display(), old_path);
let path_guard = EnvVarGuard::set("PATH", &new_path);
(bin_dir, path_guard)
}
pub struct EnvVarGuard {
key: &'static str,
prior: Option<String>,
}
impl EnvVarGuard {
pub fn set(key: &'static str, value: &str) -> Self {
let prior = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, prior }
}
pub fn unset(key: &'static str) -> Self {
let prior = std::env::var(key).ok();
unsafe {
std::env::remove_var(key);
}
Self { key, prior }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
pub struct EditorGuard {
prior: Option<String>,
}
impl EditorGuard {
pub fn set(editor: &str) -> Self {
let prior = std::env::var("EDITOR").ok();
unsafe {
std::env::set_var("EDITOR", editor);
}
Self { prior }
}
}
impl Drop for EditorGuard {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var("EDITOR", v),
None => std::env::remove_var("EDITOR"),
}
}
}
}
pub struct CwdGuard {
orig: PathBuf,
}
impl CwdGuard {
pub fn set(new: impl AsRef<Path>) -> std::io::Result<Self> {
let orig = std::env::current_dir()?;
std::env::set_current_dir(new)?;
Ok(Self { orig })
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.orig);
}
}
pub fn with_test_env_var<F: FnOnce()>(var: &str, value: Option<&str>, f: F) {
unsafe {
let prior = std::env::var(var).ok();
match value {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
f();
match prior {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
}
#[cfg(unix)]
pub struct CosignTestShim {
_tmp: tempfile::TempDir,
log_path: std::path::PathBuf,
argv_logging: bool,
prior_bin: Option<String>,
prior_log: Option<String>,
}
#[cfg(unix)]
impl CosignTestShim {
pub fn builder() -> CosignTestShimBuilder {
CosignTestShimBuilder::default()
}
pub fn install() -> Self {
Self::builder().install()
}
pub fn argv_log(&self) -> String {
if !self.argv_logging {
return String::new();
}
std::fs::read_to_string(&self.log_path).unwrap_or_default()
}
pub fn invocation_count(&self) -> usize {
self.argv_log().lines().filter(|l| !l.is_empty()).count()
}
}
#[cfg(unix)]
impl Drop for CosignTestShim {
fn drop(&mut self) {
unsafe {
match self.prior_bin.take() {
Some(v) => std::env::set_var("CFGD_COSIGN_BIN", v),
None => std::env::remove_var("CFGD_COSIGN_BIN"),
}
match self.prior_log.take() {
Some(v) => std::env::set_var("CFGD_FAKE_COSIGN_LOG", v),
None => std::env::remove_var("CFGD_FAKE_COSIGN_LOG"),
}
}
}
}
#[cfg(unix)]
pub struct CosignTestShimBuilder {
argv_logging: bool,
keygen: bool,
exit_code: i32,
stderr: String,
}
#[cfg(unix)]
impl Default for CosignTestShimBuilder {
fn default() -> Self {
Self {
argv_logging: true,
keygen: false,
exit_code: 0,
stderr: String::new(),
}
}
}
#[cfg(unix)]
impl CosignTestShimBuilder {
pub fn with_argv_logging(mut self, enabled: bool) -> Self {
self.argv_logging = enabled;
self
}
pub fn with_keygen(mut self, enabled: bool) -> Self {
self.keygen = enabled;
self
}
pub fn with_exit(mut self, code: i32) -> Self {
self.exit_code = code;
self
}
pub fn with_stderr(mut self, stderr: &str) -> Self {
self.stderr = stderr.to_string();
self
}
pub fn install(self) -> CosignTestShim {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("tempdir");
let bin_path = tmp.path().join("fake-cosign");
let log_path = tmp.path().join("argv.log");
let stderr_lit = self.stderr.replace('\'', "'\\''");
let log_line = if self.argv_logging {
"printf '%s\\n' \"$*\" >> \"$CFGD_FAKE_COSIGN_LOG\"\n"
} else {
""
};
let keygen_block = if self.keygen {
"if [ \"$1\" = \"generate-key-pair\" ]; then\n printf 'fake-private-key-bytes' > cosign.key\n printf 'fake-public-key-bytes' > cosign.pub\nfi\n"
} else {
""
};
let script = format!(
"#!/bin/sh\n{log_line}{keygen_block}printf '%s' '{stderr_lit}' 1>&2\nexit {exit}\n",
exit = self.exit_code,
);
std::fs::write(&bin_path, script).expect("write fake-cosign");
let mut perms = std::fs::metadata(&bin_path).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin_path, perms).expect("chmod");
let prior_bin = std::env::var("CFGD_COSIGN_BIN").ok();
let prior_log = std::env::var("CFGD_FAKE_COSIGN_LOG").ok();
unsafe {
std::env::set_var("CFGD_COSIGN_BIN", &bin_path);
if self.argv_logging {
std::env::set_var("CFGD_FAKE_COSIGN_LOG", &log_path);
} else {
std::env::remove_var("CFGD_FAKE_COSIGN_LOG");
}
}
CosignTestShim {
_tmp: tmp,
log_path,
argv_logging: self.argv_logging,
prior_bin,
prior_log,
}
}
}
pub struct MockPackageManager {
pub mgr_name: String,
pub available: bool,
pub bootstrap_capable: bool,
pub installed: std::collections::HashSet<String>,
pub install_calls: Mutex<Vec<Vec<String>>>,
pub uninstall_calls: Mutex<Vec<Vec<String>>>,
}
impl MockPackageManager {
pub fn new(name: &str) -> Self {
Self {
mgr_name: name.to_string(),
available: true,
bootstrap_capable: false,
installed: std::collections::HashSet::new(),
install_calls: Mutex::new(Vec::new()),
uninstall_calls: Mutex::new(Vec::new()),
}
}
pub fn with_installed(mut self, pkgs: &[&str]) -> Self {
for p in pkgs {
self.installed.insert((*p).to_string());
}
self
}
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
pub fn bootstrappable(mut self) -> Self {
self.bootstrap_capable = true;
self
}
}
impl crate::providers::PackageManager for MockPackageManager {
fn name(&self) -> &str {
&self.mgr_name
}
fn is_available(&self) -> bool {
self.available
}
fn can_bootstrap(&self) -> bool {
self.bootstrap_capable
}
fn bootstrap(&self, _printer: &Printer) -> crate::errors::Result<()> {
Ok(())
}
fn installed_packages(&self) -> crate::errors::Result<std::collections::HashSet<String>> {
Ok(self.installed.clone())
}
fn install(&self, packages: &[String], _printer: &Printer) -> crate::errors::Result<()> {
self.install_calls.lock().unwrap().push(packages.to_vec());
Ok(())
}
fn uninstall(&self, packages: &[String], _printer: &Printer) -> crate::errors::Result<()> {
self.uninstall_calls.lock().unwrap().push(packages.to_vec());
Ok(())
}
fn update(&self, _printer: &Printer) -> crate::errors::Result<()> {
Ok(())
}
fn available_version(&self, _package: &str) -> crate::errors::Result<Option<String>> {
Ok(None)
}
}
pub struct ReconcilerTestHarnessBuilder {
profile_yaml: Option<String>,
package_managers: Vec<MockPackageManager>,
system_configurators: Vec<MockSystemConfigurator>,
secret_providers: Vec<MockSecretProvider>,
file_manager: Option<MockFileManager>,
}
impl ReconcilerTestHarnessBuilder {
pub fn profile_yaml(mut self, yaml: &str) -> Self {
self.profile_yaml = Some(yaml.to_string());
self
}
pub fn package_manager(mut self, name: &str, installed: &[&str]) -> Self {
self.package_managers
.push(MockPackageManager::new(name).with_installed(installed));
self
}
pub fn system_configurator(mut self, name: &str, _drift: &[SystemDrift]) -> Self {
self.system_configurators
.push(MockSystemConfigurator::new(name));
self
}
pub fn system_configurator_with_drift(mut self, name: &str, drift: Vec<SystemDrift>) -> Self {
self.system_configurators
.push(MockSystemConfigurator::new(name).with_drift(drift));
self
}
pub fn secret_provider(mut self, name: &str, resolved_value: &str) -> Self {
self.secret_providers
.push(MockSecretProvider::new(name).with_resolve_result(resolved_value));
self
}
pub fn file_manager(mut self, fm: MockFileManager) -> Self {
self.file_manager = Some(fm);
self
}
pub fn build(self) -> ReconcilerTestHarness {
let state = test_state();
let resolved = if let Some(yaml) = &self.profile_yaml {
parse_profile_yaml_to_resolved(yaml)
} else {
make_empty_resolved()
};
let mut registry = crate::providers::ProviderRegistry::new();
for pm in self.package_managers {
registry.package_managers.push(Box::new(pm));
}
for sc in self.system_configurators {
registry.system_configurators.push(Box::new(sc));
}
for sp in self.secret_providers {
registry.secret_providers.push(Box::new(sp));
}
let fm = self.file_manager.unwrap_or_default();
registry.file_manager = Some(Box::new(fm));
ReconcilerTestHarness {
registry,
state,
resolved,
}
}
}
pub struct ReconcilerTestHarness {
pub registry: crate::providers::ProviderRegistry,
pub state: crate::state::StateStore,
pub resolved: crate::config::ResolvedProfile,
}
impl ReconcilerTestHarness {
pub fn builder() -> ReconcilerTestHarnessBuilder {
ReconcilerTestHarnessBuilder {
profile_yaml: None,
package_managers: Vec::new(),
system_configurators: Vec::new(),
secret_providers: Vec::new(),
file_manager: None,
}
}
pub fn plan(&self) -> crate::errors::Result<crate::reconciler::Plan> {
let reconciler = crate::reconciler::Reconciler::new(&self.registry, &self.state);
reconciler.plan(
&self.resolved,
Vec::new(),
Vec::new(),
Vec::new(),
crate::reconciler::ReconcileContext::Apply,
)
}
pub fn plan_with_actions(
&self,
file_actions: Vec<crate::providers::FileAction>,
pkg_actions: Vec<crate::providers::PackageAction>,
module_actions: Vec<crate::modules::ResolvedModule>,
) -> crate::errors::Result<crate::reconciler::Plan> {
let reconciler = crate::reconciler::Reconciler::new(&self.registry, &self.state);
reconciler.plan(
&self.resolved,
file_actions,
pkg_actions,
module_actions,
crate::reconciler::ReconcileContext::Apply,
)
}
pub fn apply(
&self,
plan: &crate::reconciler::Plan,
printer: &Printer,
) -> crate::errors::Result<crate::reconciler::ApplyResult> {
let reconciler = crate::reconciler::Reconciler::new(&self.registry, &self.state);
reconciler.apply(
plan,
&self.resolved,
std::path::Path::new("."),
printer,
None,
&[],
crate::reconciler::ReconcileContext::Apply,
false,
None,
)
}
pub fn state_store(&self) -> &crate::state::StateStore {
&self.state
}
pub fn resolved_profile(&self) -> &crate::config::ResolvedProfile {
&self.resolved
}
}
fn parse_profile_yaml_to_resolved(yaml: &str) -> crate::config::ResolvedProfile {
let spec = if let Ok(doc) = serde_yaml::from_str::<crate::config::ProfileDocument>(yaml) {
doc.spec
} else {
serde_yaml::from_str::<crate::config::ProfileSpec>(yaml)
.expect("failed to parse profile YAML in test harness")
};
let merged = crate::config::MergedProfile {
modules: spec.modules.clone(),
env: spec.env.clone(),
aliases: spec.aliases.clone(),
packages: spec.packages.clone().unwrap_or_default(),
files: spec.files.clone().unwrap_or_default(),
system: spec.system.clone(),
secrets: spec.secrets.clone(),
scripts: spec.scripts.clone().unwrap_or_default(),
};
crate::config::ResolvedProfile {
layers: vec![crate::config::ProfileLayer {
source: "local".to_string(),
profile_name: "harness-test".to_string(),
priority: 1000,
policy: crate::config::LayerPolicy::Local,
spec,
}],
merged,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::FileManager;
use secrecy::ExposeSecret;
#[test]
fn mock_file_manager_records_calls() {
let fm = MockFileManager::new();
let layers = vec![FileLayer {
source_dir: PathBuf::from("/tmp/src"),
origin_source: "test-origin".into(),
priority: 0,
}];
let printer = test_printer();
let tree = fm.scan_source(&layers).unwrap();
assert!(tree.files.is_empty());
assert_eq!(fm.scan_source_calls.lock().unwrap().len(), 1);
let target_tree = fm.scan_target(&[PathBuf::from("/tmp/target")]).unwrap();
assert!(target_tree.files.is_empty());
assert_eq!(fm.scan_target_calls.lock().unwrap().len(), 1);
let diffs = fm.diff(&tree, &target_tree).unwrap();
assert!(diffs.is_empty());
fm.apply(&[], &printer).unwrap();
assert_eq!(fm.apply_calls.lock().unwrap().len(), 1);
}
#[test]
fn mock_file_manager_can_fail() {
let fm = MockFileManager::new();
let printer = test_printer();
fm.set_fail_apply(true);
let result = fm.apply(&[], &printer);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("mock-failure"),
"expected mock-failure path in error, got: {err_msg}"
);
}
#[test]
fn mock_secret_backend_tracks_decrypt() {
let backend = MockSecretBackend::new("sops");
let secret = backend.decrypt_file(Path::new("/tmp/secret.enc")).unwrap();
assert_eq!(secret.expose_secret(), "mock-secret-value");
assert_eq!(backend.decrypt_calls.lock().unwrap().len(), 1);
}
#[test]
fn mock_secret_backend_can_fail() {
let backend = MockSecretBackend::new("sops");
backend.set_fail_decrypt(true);
let result = backend.decrypt_file(Path::new("/tmp/secret.enc"));
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("mock decrypt failure"),
"expected 'mock decrypt failure' in error, got: {err_msg}"
);
}
#[test]
fn mock_secret_provider_resolve() {
let provider = MockSecretProvider::new("1password");
let secret = provider.resolve("vault/item/field").unwrap();
assert_eq!(secret.expose_secret(), "mock-resolved-secret");
assert_eq!(provider.resolve_calls.lock().unwrap().len(), 1);
}
#[test]
fn mock_secret_provider_can_fail() {
let provider = MockSecretProvider::new("1password");
provider.set_fail_resolve(true);
let result = provider.resolve("vault/item/field");
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("vault/item/field"),
"expected reference in error, got: {err_msg}"
);
}
#[test]
fn mock_system_configurator_empty_state() {
let sc = MockSystemConfigurator::new("sysctl");
let state = sc.current_state().unwrap();
assert!(state.as_mapping().unwrap().is_empty());
}
#[test]
fn mock_system_configurator_with_drift() {
let sc = MockSystemConfigurator::new("sysctl").with_drift(vec![SystemDrift {
key: "net.ipv4.ip_forward".into(),
expected: "1".into(),
actual: "0".into(),
}]);
let desired = serde_yaml::Value::Null;
let drifts = sc.diff(&desired).unwrap();
assert_eq!(drifts.len(), 1);
assert_eq!(drifts[0].key, "net.ipv4.ip_forward");
}
#[test]
fn mock_system_configurator_apply_records() {
let sc = MockSystemConfigurator::new("sysctl");
let printer = test_printer();
let desired = serde_yaml::Value::String("test".into());
sc.apply(&desired, &printer).unwrap();
assert_eq!(sc.apply_calls.lock().unwrap().len(), 1);
}
#[test]
fn mock_system_configurator_can_fail() {
let sc = MockSystemConfigurator::new("sysctl");
let printer = test_printer();
sc.set_fail_apply(true);
let result = sc.apply(&serde_yaml::Value::Null, &printer);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("mock system apply failure"),
"expected 'mock system apply failure' in error, got: {err_msg}"
);
}
#[test]
fn test_env_builder_creates_dirs() {
let env = TestEnvBuilder::new()
.config("cfgd.yaml", "apiVersion: cfgd.io/v1alpha1\n")
.profile("default.yaml", "kind: Profile\n")
.module("nvim/module.yaml", "kind: Module\n")
.file("extra/data.txt", "hello\n")
.build();
assert!(env.config_dir.exists());
assert!(env.profiles_dir.exists());
assert!(env.modules_dir.exists());
assert!(env.state_dir.exists());
assert!(env.file_exists("config/cfgd.yaml"));
assert!(env.file_exists("profiles/default.yaml"));
assert!(env.file_exists("modules/nvim/module.yaml"));
assert!(env.file_exists("extra/data.txt"));
assert_eq!(env.read_at("extra/data.txt"), "hello\n");
}
#[test]
fn test_env_write_after_build() {
let env = TestEnvBuilder::new().build();
assert!(!env.file_exists("late.txt"));
env.write_file("late.txt", "added later");
assert!(env.file_exists("late.txt"));
assert_eq!(env.read_at("late.txt"), "added later");
}
#[test]
fn init_test_git_repo_creates_valid_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_dir = tmp.path().join("repo");
init_test_git_repo(&repo_dir);
let repo = git2::Repository::open(&repo_dir).unwrap();
let head = repo.head().unwrap();
assert!(head.is_branch());
let commit = head.peel_to_commit().unwrap();
assert_eq!(commit.message().unwrap(), "initial commit");
}
#[test]
fn bare_git_repo_clone_from_fixture() {
let repo = BareGitRepo::builder()
.commit("initial", &[("README.md", "hello")])
.commit(
"second",
&[("module.yaml", "apiVersion: cfgd.io/v1alpha1\n")],
)
.tag("v1.0.0")
.branch("feature", &[("extra.txt", "data")])
.build();
let url = repo.url();
assert!(
url.starts_with("file://"),
"url should be file://, got: {url}"
);
assert!(repo.has_tag("v1.0.0"));
assert!(!repo.has_tag("v2.0.0"));
assert!(repo.has_branch("feature"));
assert!(repo.has_branch(repo.head_branch()));
let clone_dir = tempfile::TempDir::new().unwrap();
let cloned = git2::Repository::clone(&url, clone_dir.path()).unwrap();
let readme = std::fs::read_to_string(clone_dir.path().join("README.md")).unwrap();
assert_eq!(crate::normalize_line_endings(&readme), "hello");
let module = std::fs::read_to_string(clone_dir.path().join("module.yaml")).unwrap();
assert_eq!(
crate::normalize_line_endings(&module),
"apiVersion: cfgd.io/v1alpha1\n"
);
let head = cloned.head().unwrap().peel_to_commit().unwrap();
assert_eq!(head.message().unwrap(), "second");
let parent = head.parent(0).unwrap();
assert_eq!(parent.message().unwrap(), "initial");
}
#[test]
fn bare_git_repo_fetch_branch_from_fixture() {
let repo = BareGitRepo::builder()
.commit("base", &[("base.txt", "base content")])
.branch("dev", &[("dev.txt", "dev content")])
.build();
let clone_dir = tempfile::TempDir::new().unwrap();
let cloned = git2::Repository::clone(&repo.url(), clone_dir.path()).unwrap();
let mut remote = cloned.find_remote("origin").unwrap();
remote
.fetch(&["refs/heads/dev:refs/remotes/origin/dev"], None, None)
.unwrap();
let dev_ref = cloned.find_reference("refs/remotes/origin/dev").unwrap();
let dev_commit = dev_ref.peel_to_commit().unwrap();
let dev_tree = dev_commit.tree().unwrap();
assert!(
dev_tree.get_name("dev.txt").is_some(),
"dev branch should contain dev.txt"
);
assert!(
dev_tree.get_name("base.txt").is_some(),
"dev branch should also contain base.txt"
);
let tags = repo.tags();
assert!(tags.is_empty(), "no tags were added");
}
#[test]
fn bare_git_repo_multiple_tags() {
let repo = BareGitRepo::builder()
.commit("first", &[("a.txt", "a")])
.tag("v0.1.0")
.commit("second", &[("b.txt", "b")])
.tag("v0.2.0")
.build();
assert!(repo.has_tag("v0.1.0"));
assert!(repo.has_tag("v0.2.0"));
assert!(!repo.has_tag("v0.3.0"));
let tags = repo.tags();
assert_eq!(tags.len(), 2);
assert!(tags.contains(&"v0.1.0".to_string()));
assert!(tags.contains(&"v0.2.0".to_string()));
}
use serial_test::serial;
#[test]
#[serial]
fn env_var_guard_set_captures_prior_and_restores_on_drop() {
const KEY: &str = "CFGD_TEST_GUARD_SET_1";
unsafe {
std::env::set_var(KEY, "original");
}
{
let _g = EnvVarGuard::set(KEY, "overridden");
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("overridden"));
}
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("original"));
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
#[serial]
fn env_var_guard_set_with_no_prior_removes_on_drop() {
const KEY: &str = "CFGD_TEST_GUARD_SET_2";
unsafe {
std::env::remove_var(KEY);
}
assert!(std::env::var(KEY).is_err());
{
let _g = EnvVarGuard::set(KEY, "value");
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("value"));
}
assert!(std::env::var(KEY).is_err());
}
#[test]
#[serial]
fn env_var_guard_unset_removes_and_restores_on_drop() {
const KEY: &str = "CFGD_TEST_GUARD_UNSET_1";
unsafe {
std::env::set_var(KEY, "before");
}
{
let _g = EnvVarGuard::unset(KEY);
assert!(std::env::var(KEY).is_err());
}
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("before"));
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
#[serial]
fn with_test_env_var_some_sets_and_restores() {
const KEY: &str = "CFGD_TEST_WITH_ENV_SOME_1";
unsafe {
std::env::set_var(KEY, "outer");
}
let mut observed = None;
with_test_env_var(KEY, Some("inner"), || {
observed = std::env::var(KEY).ok();
});
assert_eq!(observed.as_deref(), Some("inner"));
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("outer"));
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
#[serial]
fn with_test_env_var_none_removes_and_restores() {
const KEY: &str = "CFGD_TEST_WITH_ENV_NONE_1";
unsafe {
std::env::set_var(KEY, "outer");
}
let mut observed_present = true;
with_test_env_var(KEY, None, || {
observed_present = std::env::var(KEY).is_ok();
});
assert!(!observed_present);
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("outer"));
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
#[serial]
fn env_var_guard_round_trips_special_chars() {
const KEY: &str = "CFGD_TEST_GUARD_SPECIAL_1";
let weird = "a=b c\t\"quoted\" 'single' = trailing ";
unsafe {
std::env::set_var(KEY, weird);
}
{
let _g = EnvVarGuard::set(KEY, "temp");
assert_eq!(std::env::var(KEY).ok().as_deref(), Some("temp"));
}
assert_eq!(std::env::var(KEY).ok().as_deref(), Some(weird));
unsafe {
std::env::remove_var(KEY);
}
}
#[cfg(unix)]
mod cosign_shim_tests {
use super::super::CosignTestShim;
use serial_test::serial;
fn run_shim(args: &[&str]) -> (i32, String) {
let bin = std::env::var("CFGD_COSIGN_BIN").expect("CFGD_COSIGN_BIN set");
let output = std::process::Command::new(&bin)
.args(args)
.output()
.expect("spawn shim");
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
#[serial]
fn install_sets_cosign_bin_and_drop_restores_prior() {
unsafe {
std::env::set_var("CFGD_COSIGN_BIN", "/prior/value");
}
{
let _shim = CosignTestShim::install();
let observed =
std::env::var("CFGD_COSIGN_BIN").expect("install sets CFGD_COSIGN_BIN");
assert_ne!(observed, "/prior/value", "shim must override prior value");
assert!(
std::path::Path::new(&observed).is_file(),
"CFGD_COSIGN_BIN must point at the shim file"
);
}
assert_eq!(
std::env::var("CFGD_COSIGN_BIN").ok().as_deref(),
Some("/prior/value"),
"drop must restore the prior value"
);
unsafe {
std::env::remove_var("CFGD_COSIGN_BIN");
}
}
#[test]
#[serial]
fn install_with_no_prior_value_removes_on_drop() {
unsafe {
std::env::remove_var("CFGD_COSIGN_BIN");
}
assert!(std::env::var("CFGD_COSIGN_BIN").is_err());
{
let _shim = CosignTestShim::install();
assert!(std::env::var("CFGD_COSIGN_BIN").is_ok());
}
assert!(
std::env::var("CFGD_COSIGN_BIN").is_err(),
"drop must remove when no prior value existed"
);
}
#[test]
#[serial]
fn argv_logging_enabled_records_invocations() {
let shim = CosignTestShim::builder().with_argv_logging(true).install();
let (code, _) = run_shim(&["sign", "--yes", "ghcr.io/test/x:v1"]);
assert_eq!(code, 0);
let log = shim.argv_log();
assert!(log.contains("sign"), "argv log must contain `sign`: {log}");
assert!(
log.contains("--yes"),
"argv log must contain `--yes`: {log}"
);
assert!(
log.contains("ghcr.io/test/x:v1"),
"argv log must contain artifact ref: {log}"
);
assert_eq!(shim.invocation_count(), 1);
run_shim(&["verify", "ghcr.io/test/x:v1"]);
assert_eq!(shim.invocation_count(), 2);
}
#[test]
#[serial]
fn argv_logging_disabled_does_not_write_log() {
let shim = CosignTestShim::builder().with_argv_logging(false).install();
assert!(
std::env::var("CFGD_FAKE_COSIGN_LOG").is_err(),
"argv-log env var must not be set when logging is disabled"
);
let (code, _) = run_shim(&["sign", "ghcr.io/test/x:v1"]);
assert_eq!(code, 0);
assert_eq!(shim.argv_log(), "");
assert_eq!(shim.invocation_count(), 0);
}
#[test]
#[serial]
fn keygen_mode_writes_key_pair_to_cwd_on_generate_key_pair() {
let _shim = CosignTestShim::builder().with_keygen(true).install();
let workdir = tempfile::TempDir::new().expect("workdir");
let bin = std::env::var("CFGD_COSIGN_BIN").unwrap();
let status = std::process::Command::new(&bin)
.arg("generate-key-pair")
.current_dir(workdir.path())
.status()
.expect("spawn shim");
assert!(status.success(), "keygen shim must exit zero");
assert!(
workdir.path().join("cosign.key").is_file(),
"cosign.key must be written to cwd"
);
assert!(
workdir.path().join("cosign.pub").is_file(),
"cosign.pub must be written to cwd"
);
assert_eq!(
std::fs::read(workdir.path().join("cosign.key")).unwrap(),
b"fake-private-key-bytes"
);
assert_eq!(
std::fs::read(workdir.path().join("cosign.pub")).unwrap(),
b"fake-public-key-bytes"
);
}
#[test]
#[serial]
fn keygen_mode_skips_writes_for_non_generate_subcommands() {
let _shim = CosignTestShim::builder().with_keygen(true).install();
let workdir = tempfile::TempDir::new().expect("workdir");
let bin = std::env::var("CFGD_COSIGN_BIN").unwrap();
let status = std::process::Command::new(&bin)
.arg("sign")
.arg("ghcr.io/test/x:v1")
.current_dir(workdir.path())
.status()
.expect("spawn shim");
assert!(status.success());
assert!(
!workdir.path().join("cosign.key").exists(),
"non-keygen subcommand must NOT write cosign.key"
);
assert!(
!workdir.path().join("cosign.pub").exists(),
"non-keygen subcommand must NOT write cosign.pub"
);
}
#[test]
#[serial]
fn exit_code_propagates_from_with_exit() {
let _shim = CosignTestShim::builder().with_exit(1).install();
let (code, _) = run_shim(&["sign", "ghcr.io/test/x:v1"]);
assert_eq!(code, 1, "with_exit(1) must surface as non-zero exit");
}
#[test]
#[serial]
fn stderr_is_captured_from_with_stderr() {
let _shim = CosignTestShim::builder()
.with_exit(2)
.with_stderr("oops something broke")
.install();
let (code, stderr) = run_shim(&["verify", "ghcr.io/test/x:v1"]);
assert_eq!(code, 2);
assert!(
stderr.contains("oops something broke"),
"shim stderr must surface: {stderr}"
);
}
#[test]
#[serial]
fn stderr_round_trips_single_quotes() {
let _shim = CosignTestShim::builder()
.with_exit(1)
.with_stderr("can't connect — 'rekor' down")
.install();
let (_code, stderr) = run_shim(&["sign"]);
assert!(
stderr.contains("can't connect — 'rekor' down"),
"single-quote-laden stderr must round-trip: {stderr}"
);
}
}
mod reconciler_test_harness {
use super::super::*;
use crate::providers::PackageAction;
use crate::state::ApplyStatus;
use secrecy::ExposeSecret;
#[test]
fn harness_plan_empty_profile_produces_eight_phases() {
let h = ReconcilerTestHarness::builder()
.package_manager("brew", &["curl", "git"])
.system_configurator("shell", &[])
.build();
let plan = h.plan().unwrap();
assert_eq!(plan.phases.len(), 8);
assert!(plan.is_empty());
}
#[test]
fn harness_apply_empty_plan_succeeds() {
let h = ReconcilerTestHarness::builder()
.package_manager("brew", &["curl", "git"])
.build();
let plan = h.plan().unwrap();
let printer = test_printer();
let result = h.apply(&plan, &printer).unwrap();
assert_eq!(result.status, ApplyStatus::Success);
assert_eq!(result.action_results.len(), 0);
}
#[test]
fn harness_plan_with_package_actions() {
let h = ReconcilerTestHarness::builder()
.package_manager("brew", &["curl"])
.build();
let pkg_actions = vec![PackageAction::Install {
manager: "brew".to_string(),
packages: vec!["ripgrep".to_string()],
origin: "local".to_string(),
}];
let plan = h
.plan_with_actions(Vec::new(), pkg_actions, Vec::new())
.unwrap();
assert!(!plan.is_empty());
assert_eq!(plan.total_actions(), 1);
}
#[test]
fn harness_with_secret_provider() {
let h = ReconcilerTestHarness::builder()
.secret_provider("1password", "s3cr3t-value")
.build();
assert_eq!(h.registry.secret_providers.len(), 1);
let plan = h.plan().unwrap();
assert!(plan.is_empty());
let secret = h.registry.secret_providers[0]
.resolve("op://vault/item/field")
.unwrap();
assert_eq!(secret.expose_secret(), "s3cr3t-value");
}
#[test]
fn harness_with_profile_yaml() {
let yaml = r#"
modules:
- nvim
env:
- name: EDITOR
value: nvim
"#;
let h = ReconcilerTestHarness::builder()
.profile_yaml(yaml)
.package_manager("brew", &[])
.build();
assert_eq!(h.resolved_profile().merged.modules, vec!["nvim"]);
assert_eq!(h.resolved_profile().merged.env.len(), 1);
assert_eq!(h.resolved_profile().merged.env[0].name, "EDITOR");
}
#[test]
fn harness_apply_records_in_state_store() {
let h = ReconcilerTestHarness::builder().build();
let plan = h.plan().unwrap();
let printer = test_printer();
let result = h.apply(&plan, &printer).unwrap();
assert_eq!(result.status, ApplyStatus::Success);
let history = h.state_store().history(10).unwrap();
assert_eq!(history.len(), 1);
}
#[test]
fn harness_plan_with_system_configurator_drift() {
use crate::providers::SystemDrift;
let drift = SystemDrift {
key: "net.ipv4.ip_forward".into(),
expected: "1".into(),
actual: "0".into(),
};
let h = ReconcilerTestHarness::builder()
.system_configurator_with_drift("sysctl", vec![drift])
.build();
assert_eq!(h.registry.system_configurators.len(), 1);
let plan = h.plan().unwrap();
assert_eq!(plan.phases.len(), 8);
}
#[test]
fn mock_package_manager_records_install_calls() {
use crate::providers::PackageManager;
let pm = super::super::MockPackageManager::new("brew").with_installed(&["curl", "git"]);
assert!(pm.is_available());
assert_eq!(pm.name(), "brew");
let installed = pm.installed_packages().unwrap();
assert!(installed.contains("curl"));
assert!(installed.contains("git"));
assert!(!installed.contains("ripgrep"));
let printer = test_printer();
pm.install(&["ripgrep".to_string(), "fd".to_string()], &printer)
.unwrap();
let calls = pm.install_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], vec!["ripgrep".to_string(), "fd".to_string()]);
}
#[test]
fn harness_apply_with_context() {
let h = ReconcilerTestHarness::builder()
.package_manager("apt", &["vim"])
.system_configurator("shell", &[])
.secret_provider("vault", "token-123")
.build();
let plan = h.plan().unwrap();
let printer = test_printer();
let result = h.apply(&plan, &printer).unwrap();
assert_eq!(result.status, ApplyStatus::Success);
assert_eq!(h.registry.package_managers.len(), 1);
assert_eq!(h.registry.system_configurators.len(), 1);
assert_eq!(h.registry.secret_providers.len(), 1);
assert!(h.registry.file_manager.is_some());
}
}
}