use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use assert_cmd::Command;
use assert_fs::TempDir;
use predicates::prelude::*;
use serial_test::serial;
fn init_git_repo(dir: &Path) {
run_git(dir, &["init", "-q"]);
run_git(dir, &["config", "user.email", "test@example.com"]);
run_git(dir, &["config", "user.name", "Sopsy Test"]);
}
fn run_git(dir: &Path, args: &[&str]) {
let status = StdCommand::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git should be installed");
assert!(status.success(), "git {args:?} failed");
}
fn generate_age_key(dir: &Path) -> (String, PathBuf) {
let key_file = dir.join("age-key.txt");
let output = StdCommand::new("age-keygen")
.arg("-o")
.arg(&key_file)
.output()
.expect("age-keygen should be installed");
assert!(
output.status.success(),
"age-keygen failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let public_key = stderr
.lines()
.find_map(|line| line.split("Public key:").nth(1))
.map(|s| s.trim().to_string())
.expect("age-keygen should print the public key");
(public_key, key_file)
}
fn sopsy_in(dir: &Path) -> Command {
let mut cmd = Command::cargo_bin("sopsy").expect("binary `sopsy` should build");
cmd.current_dir(dir);
cmd
}
fn write_script(path: &Path, body: &str) {
std::fs::write(path, format!("#!/bin/sh\n{body}")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
}
#[test]
#[serial]
fn init_with_public_key_encrypts_real_env() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, key_file) = generate_age_key(dir.path());
let plaintext = "PASSWORD=hunter2\nAPI_KEY=abc123\n"; std::fs::write(dir.path().join(".env"), plaintext).unwrap();
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
"--recipient-name",
"primary",
])
.assert()
.success();
for name in [".sops.yaml", ".env.example", ".env.encrypted", ".sopsy.yml"] {
assert!(
dir.path().join(name).exists(),
"{name} should have been created"
);
}
let encrypted = std::fs::read_to_string(dir.path().join(".env.encrypted")).unwrap();
assert!(encrypted.contains("ENC["), "missing ENC[ markers");
assert!(encrypted.contains("sops"), "missing sops metadata");
assert_ne!(encrypted, plaintext, "file was not encrypted");
let decrypted = StdCommand::new("sops")
.env("SOPS_AGE_KEY_FILE", &key_file)
.args([
"--decrypt",
"--input-type",
"dotenv",
"--output-type",
"dotenv",
])
.arg(dir.path().join(".env.encrypted"))
.output()
.unwrap();
assert!(
decrypted.status.success(),
"decrypt failed: {}",
String::from_utf8_lossy(&decrypted.stderr)
);
assert_eq!(String::from_utf8_lossy(&decrypted.stdout), plaintext);
let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gitignore.lines().any(|l| l == ".env"), "{gitignore}");
let config = std::fs::read_to_string(dir.path().join(".sopsy.yml")).unwrap();
assert!(config.contains(&public_key), "recipient key not recorded");
assert!(config.contains("primary"), "recipient name not recorded");
}
#[test]
#[serial]
fn init_seeds_from_env_example_when_no_dotenv() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, key_file) = generate_age_key(dir.path());
let example = "FROM_EXAMPLE=yes\nTOKEN=seed-me\n";
std::fs::write(dir.path().join(".env.example"), example).unwrap();
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
])
.assert()
.success()
.stdout(predicate::str::contains(".env.example already present"));
let decrypted = StdCommand::new("sops")
.env("SOPS_AGE_KEY_FILE", &key_file)
.args([
"--decrypt",
"--input-type",
"dotenv",
"--output-type",
"dotenv",
])
.arg(dir.path().join(".env.encrypted"))
.output()
.unwrap();
assert!(
decrypted.status.success(),
"decrypt failed: {}",
String::from_utf8_lossy(&decrypted.stderr)
);
assert_eq!(String::from_utf8_lossy(&decrypted.stdout), example);
}
#[test]
#[serial]
fn init_rescues_encrypted_artifacts_in_legacy_gitignore() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, _key_file) = generate_age_key(dir.path());
std::fs::write(dir.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
])
.assert()
.success();
let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gitignore.contains("!*.encrypted"), "{gitignore}");
assert_eq!(
gitignore.matches("\n.env.*").count() + usize::from(gitignore.starts_with(".env.*")),
1,
"must not duplicate the pre-existing rule:\n{gitignore}"
);
for committable in [".env.example.encrypted", ".env.example"] {
std::fs::write(dir.path().join(committable), "x").unwrap();
let ignored = StdCommand::new("git")
.arg("-C")
.arg(dir.path())
.args(["check-ignore", committable])
.status()
.unwrap()
.success();
assert!(
!ignored,
"{committable} must be committable, not gitignored"
);
}
std::fs::write(dir.path().join(".env.local"), "x").unwrap();
let env_local_ignored = StdCommand::new("git")
.arg("-C")
.arg(dir.path())
.args(["check-ignore", ".env.local"])
.status()
.unwrap()
.success();
assert!(env_local_ignored, ".env.local must still be ignored");
}
#[test]
#[serial]
fn rerun_preserves_sops_yaml_unless_forced() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, _key_file) = generate_age_key(dir.path());
let args = [
"--non-interactive",
"init",
"--no-generate",
"--public-key",
public_key.as_str(),
];
sopsy_in(dir.path()).args(args).assert().success();
let sops_yaml = dir.path().join(".sops.yaml");
let original = std::fs::read_to_string(&sops_yaml).unwrap();
std::fs::write(&sops_yaml, format!("{original}# SENTINEL\n")).unwrap();
sopsy_in(dir.path()).args(args).assert().success();
assert!(
std::fs::read_to_string(&sops_yaml)
.unwrap()
.contains("# SENTINEL"),
"non-forced re-run must not clobber .sops.yaml"
);
sopsy_in(dir.path())
.args(args)
.arg("--force")
.assert()
.success();
assert!(
!std::fs::read_to_string(&sops_yaml)
.unwrap()
.contains("# SENTINEL"),
"--force must rewrite .sops.yaml"
);
}
#[test]
#[serial]
fn init_generates_enclave_identity_with_fakes() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let fake_pubkey = "age1se1qg8vwwqhztnh3vpt2nf2xwn7famktxlmp0nmkfltp8lkvzp8nafkqleh258";
let plugin = dir.path().join("age-plugin-se");
write_script(
&plugin,
&format!("cat <<'EOF'\n# public key: {fake_pubkey}\nAGE-PLUGIN-SE-1QFAKEIDENTITY\nEOF\n"),
);
let record = dir.path().join("sops-invocations.log");
let sops = dir.path().join("sops");
write_script(
&sops,
&format!(
"case \"$1\" in\n --version) echo 'sops 9.9.9 (fake)'; exit 0;;\nesac\n\
echo \"$@\" >> '{record}'\n\
for a in \"$@\"; do f=\"$a\"; done\n\
echo 'ENC[fake-sops]' > \"$f\"\n",
record = record.display()
),
);
sopsy_in(dir.path())
.env("SOPSY_AGE_PLUGIN_SE_BIN", &plugin)
.env("SOPSY_SOPS_BIN", &sops)
.env("SOPSY_KEYS_FILE", dir.path().join("age-keys.txt"))
.args(["--non-interactive", "init"])
.assert()
.success();
let keys = std::fs::read_to_string(dir.path().join("age-keys.txt")).unwrap();
assert!(
keys.contains("AGE-PLUGIN-SE-1QFAKEIDENTITY"),
"identity should be persisted to the keystore so sops can decrypt"
);
let config = std::fs::read_to_string(dir.path().join(".sopsy.yml")).unwrap();
assert!(
config.contains(fake_pubkey),
"enclave identity not recorded in .sopsy.yml:\n{config}"
);
let log = std::fs::read_to_string(&record).unwrap();
assert!(log.contains("--encrypt"), "sops encrypt not invoked: {log}");
assert!(log.contains("--in-place"), "sops not run in place: {log}");
let encrypted = std::fs::read_to_string(dir.path().join(".env.encrypted")).unwrap();
assert!(
encrypted.contains("ENC[fake-sops]"),
"fake sops did not encrypt"
);
}
#[test]
#[serial]
fn non_interactive_without_key_or_generation_fails_friendly() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
sopsy_in(dir.path())
.args(["--non-interactive", "init", "--no-generate"])
.assert()
.failure()
.stderr(predicate::str::contains("--public-key"));
assert!(!dir.path().join(".sops.yaml").exists());
}
#[test]
fn init_outside_git_repo_fails_friendly() {
let dir = TempDir::new().unwrap();
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
"age1abc",
])
.assert()
.failure()
.stderr(predicate::str::contains("git repository"));
}
#[test]
#[serial]
fn init_records_username_and_respects_no_break_glass() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, _key_file) = generate_age_key(dir.path());
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
"--username",
"alice",
"--no-break-glass",
])
.assert()
.success();
let config = std::fs::read_to_string(dir.path().join(".sopsy.yml")).unwrap();
assert!(
config.contains("username: alice"),
"username should be recorded"
);
assert!(
!config.contains("break_glass: true"),
"--no-break-glass must skip break-glass setup"
);
}
#[test]
#[serial]
fn init_without_break_glass_prints_guidance() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, _key_file) = generate_age_key(dir.path());
sopsy_in(dir.path())
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
])
.assert()
.success()
.stdout(predicate::str::contains("sopsy recipient break-glass"));
let config = std::fs::read_to_string(dir.path().join(".sopsy.yml")).unwrap();
assert!(
!config.contains("break_glass: true"),
"no break-glass should be registered without the flag"
);
}
#[test]
#[serial]
fn init_break_glass_flag_registers_offline_key() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let (public_key, key_file) = generate_age_key(dir.path());
sopsy_in(dir.path())
.env("SOPS_AGE_KEY_FILE", &key_file)
.env("SOPSY_ASSUME_YES", "1")
.args([
"--non-interactive",
"init",
"--no-generate",
"--public-key",
&public_key,
"--break-glass",
])
.assert()
.success();
let config = std::fs::read_to_string(dir.path().join(".sopsy.yml")).unwrap();
assert!(
config.contains("break_glass: true"),
"break-glass recipient should be recorded:\n{config}"
);
assert!(!dir.path().join("break-glass.private").exists());
assert!(!dir.path().join("break-glass.public").exists());
let sops_yaml = std::fs::read_to_string(dir.path().join(".sops.yaml")).unwrap();
assert!(
sops_yaml.contains(&public_key),
"owner key must remain a recipient"
);
let decrypted = StdCommand::new("sops")
.env("SOPS_AGE_KEY_FILE", &key_file)
.args([
"--decrypt",
"--input-type",
"dotenv",
"--output-type",
"dotenv",
])
.arg(dir.path().join(".env.encrypted"))
.output()
.unwrap();
assert!(
decrypted.status.success(),
"owner should still decrypt after break-glass re-key: {}",
String::from_utf8_lossy(&decrypted.stderr)
);
}