doot/crates/doot-cli/tests/e2e.rs

726 lines
21 KiB
Rust

use std::path::PathBuf;
use std::process::Command;
struct Sandbox {
path: PathBuf,
}
impl Sandbox {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("doot-test-{}", name));
if path.exists() {
std::fs::remove_dir_all(&path).unwrap();
}
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
fn run(&self, args: &[&str]) -> std::process::Output {
let doot = env!("CARGO_BIN_EXE_doot");
Command::new(doot)
.args(args)
.env("DOOT_HOME", &self.path)
.env("DOOT_TEST_MODE", "1")
.output()
.expect("failed to run doot")
}
fn config_dir(&self) -> PathBuf {
self.path.join(".config/doot")
}
fn state_dir(&self) -> PathBuf {
self.path.join(".local/state/doot")
}
fn config_file(&self) -> PathBuf {
self.config_dir().join("doot.doot")
}
fn write_config(&self, content: &str) {
std::fs::create_dir_all(self.config_dir()).unwrap();
std::fs::write(self.config_file(), content).unwrap();
}
fn write_source(&self, path: &str, content: &str) {
let full_path = self.config_dir().join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(full_path, content).unwrap();
}
fn is_symlink(&self, path: &PathBuf) -> bool {
path.is_symlink()
}
fn symlink_target(&self, path: &PathBuf) -> Option<PathBuf> {
std::fs::read_link(path).ok()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn test_init_creates_structure() {
let sandbox = Sandbox::new("init");
let output = sandbox.run(&["init"]);
assert!(output.status.success(), "init failed: {:?}", output);
assert!(sandbox.config_file().exists(), "config file not created");
assert!(
sandbox.config_dir().join("config").exists(),
"config dir not created"
);
assert!(
sandbox.state_dir().join("backups").exists(),
"backups dir not created"
);
assert!(
sandbox.state_dir().join("snapshots").exists(),
"snapshots dir not created"
);
}
#[test]
fn test_check_valid_config() {
let sandbox = Sandbox::new("check-valid");
sandbox.write_config(
r#"
package: "ripgrep"
package: "fd"
"#,
);
let output = sandbox.run(&["check"]);
assert!(output.status.success(), "check failed: {:?}", output);
}
#[test]
fn test_apply_dry_run() {
let sandbox = Sandbox::new("apply-dry");
sandbox.write_config(
r#"
dotfile:
source = "config/test.conf"
target = "~/.config/test/test.conf"
"#,
);
sandbox.write_source("config/test.conf", "test content");
let output = sandbox.run(&["apply", "-n"]);
assert!(output.status.success(), "apply -n failed: {:?}", output);
let target = sandbox.path.join(".config/test/test.conf");
assert!(!target.exists(), "dry run should not create files");
}
#[test]
fn test_apply_creates_symlink() {
let sandbox = Sandbox::new("apply-symlink");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
deploy = "link"
"#,
);
sandbox.write_source("config/app.conf", "app config content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/app/app.conf");
assert!(sandbox.is_symlink(&target), "target should be symlink");
let expected_source = sandbox.config_dir().join("config/app.conf");
assert_eq!(
sandbox.symlink_target(&target),
Some(expected_source),
"symlink should point to source"
);
}
#[test]
fn test_apply_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n",
);
sandbox.write_source("config/app.conf", "content");
let first = sandbox.run(&["apply"]);
assert!(first.status.success(), "first apply failed");
let second = sandbox.run(&["apply"]);
assert!(second.status.success(), "second apply failed");
// Second apply should succeed (symlink already exists and points correctly)
let target = sandbox.path.join(".config/app/app.conf");
assert!(
target.is_symlink(),
"target should still be symlink after second apply"
);
}
#[test]
fn test_apply_creates_copy() {
let sandbox = Sandbox::new("apply-copy");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "app config content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/app/app.conf");
assert!(target.exists(), "target should exist");
assert!(
!target.is_symlink(),
"target should be a copy, not a symlink"
);
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(content, "app config content", "content should match source");
}
#[test]
fn test_apply_copy_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-copy-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n",
);
sandbox.write_source("config/app.conf", "content");
let first = sandbox.run(&["apply"]);
assert!(first.status.success(), "first apply failed");
let second = sandbox.run(&["apply"]);
assert!(second.status.success(), "second apply failed");
let target = sandbox.path.join(".config/app/app.conf");
assert!(target.exists(), "target should exist after second apply");
assert!(!target.is_symlink(), "target should still be a copy");
}
#[test]
fn test_status_shows_state() {
let sandbox = Sandbox::new("status");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "content");
sandbox.run(&["apply"]);
let output = sandbox.run(&["status"]);
assert!(output.status.success(), "status failed: {:?}", output);
}
#[test]
fn test_snapshot_and_rollback() {
let sandbox = Sandbox::new("snapshot");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "v1");
sandbox.run(&["apply"]);
let snap_output = sandbox.run(&["snapshot", "v1"]);
assert!(
snap_output.status.success(),
"snapshot failed: {:?}",
snap_output
);
let snapshot_file = sandbox.state_dir().join("snapshots/v1.json");
assert!(snapshot_file.exists(), "snapshot file not created");
}
#[test]
fn test_dotfile_with_when_condition() {
let sandbox = Sandbox::new("conditional");
// Test that 'when' condition works - only deploy if condition is true
let config = r#"dotfile:
source = "config/test.conf"
target = "~/.config/test.conf"
when = true
"#;
sandbox.write_config(config);
sandbox.write_source("config/test.conf", "test content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/test.conf");
assert!(
target.exists(),
"file should be deployed when condition is true"
);
}
#[test]
fn test_dotfile_when_false_skips() {
let sandbox = Sandbox::new("when-false");
let config = r#"dotfile:
source = "config/skip.conf"
target = "~/.config/skip.conf"
when = false
"#;
sandbox.write_config(config);
sandbox.write_source("config/skip.conf", "should not deploy");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/skip.conf");
assert!(
!target.exists(),
"file should NOT be deployed when condition is false"
);
}
#[test]
fn test_diff_shows_changes() {
let sandbox = Sandbox::new("diff");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "new content");
let target_dir = sandbox.path.join(".config/app");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::write(target_dir.join("app.conf"), "old content").unwrap();
let output = sandbox.run(&["diff"]);
assert!(output.status.success(), "diff failed: {:?}", output);
}
// ── Keygen tests ──
#[test]
fn test_keygen_creates_identity_and_recipient() {
let sandbox = Sandbox::new("keygen");
let output = sandbox.run(&["keygen", "--force"]);
assert!(output.status.success(), "keygen failed: {:?}", output);
let identity_file = sandbox.config_dir().join("identity.txt");
let recipient_file = sandbox.config_dir().join("recipient.txt");
assert!(identity_file.exists(), "identity.txt not created");
assert!(recipient_file.exists(), "recipient.txt not created");
let identity_content = std::fs::read_to_string(&identity_file).unwrap();
assert!(
identity_content.contains("AGE-SECRET-KEY-"),
"identity should contain secret key"
);
assert!(
identity_content.contains("# public key: age1"),
"identity should contain public key comment"
);
let recipient_content = std::fs::read_to_string(&recipient_file).unwrap();
assert!(
recipient_content.starts_with("age1"),
"recipient.txt should contain public key"
);
// stdout should print the public key
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.trim().starts_with("age1"),
"stdout should print public key"
);
// Check identity file permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&identity_file).unwrap().permissions();
assert_eq!(
perms.mode() & 0o777,
0o600,
"identity file should have 0600 permissions"
);
}
}
#[test]
fn test_keygen_custom_output() {
let sandbox = Sandbox::new("keygen-custom");
let custom_path = sandbox.path.join("custom/my-identity.txt");
let output = sandbox.run(&[
"keygen",
"--output",
custom_path.to_str().unwrap(),
"--force",
]);
assert!(output.status.success(), "keygen failed: {:?}", output);
assert!(custom_path.exists(), "custom identity file not created");
let recipient_file = sandbox.path.join("custom/recipient.txt");
assert!(
recipient_file.exists(),
"recipient.txt should be created alongside identity"
);
}
#[test]
fn test_keygen_appends_to_existing_recipient() {
let sandbox = Sandbox::new("keygen-append");
// Run keygen twice
let first = sandbox.run(&["keygen", "--force"]);
assert!(first.status.success(), "first keygen failed");
let first_pubkey = String::from_utf8_lossy(&first.stdout).trim().to_string();
let second = sandbox.run(&["keygen", "--force"]);
assert!(second.status.success(), "second keygen failed");
let second_pubkey = String::from_utf8_lossy(&second.stdout).trim().to_string();
// recipient.txt should contain the latest public key
let recipient_content =
std::fs::read_to_string(sandbox.config_dir().join("recipient.txt")).unwrap();
// If keys differ, both should be present
if first_pubkey != second_pubkey {
assert!(
recipient_content.contains(&second_pubkey),
"recipient.txt should contain second public key"
);
}
}
// ── Encrypt / Decrypt tests ──
#[test]
fn test_encrypt_and_decrypt_file() {
let sandbox = Sandbox::new("encrypt-decrypt");
// Generate keypair first
let keygen_output = sandbox.run(&["keygen", "--force"]);
assert!(keygen_output.status.success(), "keygen failed");
let pubkey = String::from_utf8_lossy(&keygen_output.stdout)
.trim()
.to_string();
// Create a plaintext file
let plaintext_file = sandbox.path.join("secret.txt");
std::fs::write(&plaintext_file, "hello world").unwrap();
// Encrypt it
let encrypt_output = sandbox.run(&[
"encrypt",
plaintext_file.to_str().unwrap(),
"--recipient",
&pubkey,
]);
assert!(
encrypt_output.status.success(),
"encrypt failed: {:?}",
encrypt_output
);
let encrypted_file = sandbox.path.join("secret.txt.age");
assert!(encrypted_file.exists(), "encrypted file not created");
// Encrypted file should NOT contain plaintext
let encrypted_bytes = std::fs::read(&encrypted_file).unwrap();
assert_ne!(
encrypted_bytes, b"hello world",
"encrypted file should not be plaintext"
);
// Decrypt it
let identity_file = sandbox.config_dir().join("identity.txt");
let decrypt_output = sandbox.run(&[
"decrypt",
encrypted_file.to_str().unwrap(),
"--identity",
identity_file.to_str().unwrap(),
"--output",
sandbox.path.join("decrypted.txt").to_str().unwrap(),
]);
assert!(
decrypt_output.status.success(),
"decrypt failed: {:?}",
decrypt_output
);
let decrypted = std::fs::read_to_string(sandbox.path.join("decrypted.txt")).unwrap();
assert_eq!(decrypted, "hello world", "decrypted content should match");
}
#[test]
fn test_encrypt_decrypt_to_stdout() {
let sandbox = Sandbox::new("decrypt-stdout");
let keygen_output = sandbox.run(&["keygen", "--force"]);
assert!(keygen_output.status.success());
let pubkey = String::from_utf8_lossy(&keygen_output.stdout)
.trim()
.to_string();
let plaintext_file = sandbox.path.join("data.txt");
std::fs::write(&plaintext_file, "secret data").unwrap();
let encrypt_output = sandbox.run(&[
"encrypt",
plaintext_file.to_str().unwrap(),
"--recipient",
&pubkey,
]);
assert!(encrypt_output.status.success());
let encrypted_file = sandbox.path.join("data.txt.age");
let identity_file = sandbox.config_dir().join("identity.txt");
// Decrypt to stdout (no --output)
let decrypt_output = sandbox.run(&[
"decrypt",
encrypted_file.to_str().unwrap(),
"--identity",
identity_file.to_str().unwrap(),
]);
assert!(
decrypt_output.status.success(),
"decrypt to stdout failed: {:?}",
decrypt_output
);
let stdout = String::from_utf8_lossy(&decrypt_output.stdout);
assert_eq!(stdout, "secret data", "stdout should contain plaintext");
}
#[test]
fn test_encrypt_no_recipient_fails() {
let sandbox = Sandbox::new("encrypt-no-recipient");
let plaintext_file = sandbox.path.join("file.txt");
std::fs::write(&plaintext_file, "data").unwrap();
// No recipient provided and no recipient.txt exists
let output = sandbox.run(&["encrypt", plaintext_file.to_str().unwrap()]);
assert!(
!output.status.success(),
"encrypt without recipient should fail"
);
}
#[test]
fn test_decrypt_no_identity_fails() {
let sandbox = Sandbox::new("decrypt-no-identity");
let dummy_file = sandbox.path.join("dummy.age");
std::fs::write(&dummy_file, "not really encrypted").unwrap();
let output = sandbox.run(&["decrypt", dummy_file.to_str().unwrap()]);
assert!(
!output.status.success(),
"decrypt without identity should fail"
);
}
#[test]
fn test_encrypt_var_and_decrypt_var() {
let sandbox = Sandbox::new("encrypt-decrypt-var");
let keygen_output = sandbox.run(&["keygen", "--force"]);
assert!(keygen_output.status.success());
let pubkey = String::from_utf8_lossy(&keygen_output.stdout)
.trim()
.to_string();
let identity_file = sandbox.config_dir().join("identity.txt");
// Encrypt a var (VALUE is a positional argument)
let encrypt_output = sandbox.run(&["encrypt-var", "my-secret-value", "--recipient", &pubkey]);
assert!(
encrypt_output.status.success(),
"encrypt-var failed: {:?}",
encrypt_output
);
let encrypted_b64 = String::from_utf8_lossy(&encrypt_output.stdout)
.trim()
.to_string();
assert!(
!encrypted_b64.is_empty(),
"encrypted var should produce base64 output"
);
// Decrypt it back (VALUE is a positional argument)
let decrypt_output = sandbox.run(&[
"decrypt-var",
&encrypted_b64,
"--identity",
identity_file.to_str().unwrap(),
]);
assert!(
decrypt_output.status.success(),
"decrypt-var failed: {:?}",
decrypt_output
);
let decrypted = String::from_utf8_lossy(&decrypt_output.stdout);
assert_eq!(
decrypted, "my-secret-value",
"decrypted var should match original"
);
}
// ── Reencrypt tests ──
#[test]
fn test_reencrypt_age_files() {
let sandbox = Sandbox::new("reencrypt");
// Generate two keypairs
let keygen1 = sandbox.run(&["keygen", "--force"]);
assert!(keygen1.status.success());
let pubkey1 = String::from_utf8_lossy(&keygen1.stdout).trim().to_string();
// Save first identity for decryption
let identity_content =
std::fs::read_to_string(sandbox.config_dir().join("identity.txt")).unwrap();
let keygen2 = sandbox.run(&["keygen", "--force"]);
assert!(keygen2.status.success());
let pubkey2 = String::from_utf8_lossy(&keygen2.stdout).trim().to_string();
// Create a plaintext file and encrypt with first key only
let plaintext_file = sandbox.path.join("secret.txt");
std::fs::write(&plaintext_file, "reencrypt me").unwrap();
let encrypt_output = sandbox.run(&[
"encrypt",
plaintext_file.to_str().unwrap(),
"--recipient",
&pubkey1,
]);
assert!(encrypt_output.status.success());
// Set up config with encrypted file reference in secrets dir
let secrets_dir = sandbox.config_dir().join("secrets");
std::fs::create_dir_all(&secrets_dir).unwrap();
std::fs::copy(
sandbox.path.join("secret.txt.age"),
secrets_dir.join("secret.txt.age"),
)
.unwrap();
sandbox.write_config(
r#"
package: "git"
"#,
);
// Write the first identity back (reencrypt needs it to decrypt)
std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap();
// Reencrypt with both recipients
let reencrypt_output = sandbox.run(&[
"reencrypt",
"--recipient",
&pubkey1,
"--recipient",
&pubkey2,
]);
assert!(
reencrypt_output.status.success(),
"reencrypt failed: {:?}\nstderr: {}",
reencrypt_output,
String::from_utf8_lossy(&reencrypt_output.stderr)
);
// The re-encrypted file should still be decryptable with the first identity
let identity_file = sandbox.config_dir().join("identity.txt");
let decrypt_output = sandbox.run(&[
"decrypt",
secrets_dir.join("secret.txt.age").to_str().unwrap(),
"--identity",
identity_file.to_str().unwrap(),
"--output",
sandbox.path.join("decrypted.txt").to_str().unwrap(),
]);
assert!(
decrypt_output.status.success(),
"decrypt after reencrypt failed: {:?}",
decrypt_output
);
let decrypted = std::fs::read_to_string(sandbox.path.join("decrypted.txt")).unwrap();
assert_eq!(
decrypted, "reencrypt me",
"content should survive reencryption"
);
}
#[test]
fn test_reencrypt_no_identity_fails() {
let sandbox = Sandbox::new("reencrypt-no-id");
sandbox.write_config("package: \"git\"\n");
let output = sandbox.run(&[
"reencrypt",
"--recipient",
"age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
]);
assert!(
!output.status.success(),
"reencrypt without identity should fail"
);
}
#[test]
fn test_reencrypt_no_age_files_reports_zero() {
let sandbox = Sandbox::new("reencrypt-none");
let keygen_output = sandbox.run(&["keygen", "--force"]);
assert!(keygen_output.status.success());
let pubkey = String::from_utf8_lossy(&keygen_output.stdout)
.trim()
.to_string();
sandbox.write_config("package: \"git\"\n");
let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]);
assert!(output.status.success(), "reencrypt failed: {:?}", output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("no .age files found"),
"should report no files to reencrypt, got: {}",
stdout
);
}