use std::path::{Path, PathBuf};
use std::process::Command;
use assert_fs::TempDir;
use serial_test::serial;
use sopsy::cli::{
ApproveArgs, JoinArgs, RecipientAddArgs, RecipientBreakGlassArgs, RecipientCommand,
RecipientRemoveArgs,
};
use sopsy::commands::{approve, join, recipient};
use sopsy::config::{Config, MemberState};
use sopsy::error::Error;
use sopsy::sops::{self, FileType};
use sopsy::ui::Ui;
fn test_ui() -> Ui {
Ui::new(false, false, false)
}
fn color_ui() -> Ui {
Ui::new(true, false, false)
}
fn generate_age_key(dir: &Path, file: &str) -> (String, PathBuf) {
let key_file = dir.join(file);
let output = Command::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 git_init(dir: &Path) {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("init")
.output()
.expect("git should be installed");
assert!(
output.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn write_sops_yaml(dir: &Path, public_key: &str) {
let config =
format!("creation_rules:\n - path_regex: \\.env\\.encrypted$\n age: {public_key}\n");
std::fs::write(dir.join(".sops.yaml"), config).unwrap();
}
fn write_sopsy_yml(dir: &Path, public_key: &str) {
let config = format!(
"recipients:\n - name: original\n public_key: {public_key}\n break_glass: false\n"
);
std::fs::write(dir.join(".sopsy.yml"), config).unwrap();
}
fn set_age_key_file(path: Option<&Path>) {
unsafe {
match path {
Some(p) => std::env::set_var("SOPS_AGE_KEY_FILE", p),
None => std::env::remove_var("SOPS_AGE_KEY_FILE"),
}
}
}
fn setup_repo(dir: &Path) -> (PathBuf, KeyPair, KeyPair) {
let (a_pub, a_file) = generate_age_key(dir, "keyA.txt");
let (b_pub, b_file) = generate_age_key(dir, "keyB.txt");
git_init(dir);
write_sops_yaml(dir, &a_pub);
write_sopsy_yml(dir, &a_pub);
std::env::set_current_dir(dir).unwrap();
let encrypted = dir.join(".env.encrypted");
std::fs::write(&encrypted, "FOO=bar\n").unwrap();
sops::encrypt_in_place(&encrypted, FileType::Dotenv).unwrap();
(
dir.to_path_buf(),
KeyPair {
public: a_pub,
file: a_file,
},
KeyPair {
public: b_pub,
file: b_file,
},
)
}
struct KeyPair {
public: String,
file: PathBuf,
}
fn restore_cwd(original: &Path) {
std::env::set_current_dir(original).unwrap();
}
fn add_command(
name: &str,
public_key: &str,
break_glass: bool,
no_updatekeys: bool,
) -> RecipientCommand {
RecipientCommand::Add(RecipientAddArgs {
name_pos: None,
name: Some(name.to_string()),
public_key: Some(public_key.to_string()),
break_glass,
no_updatekeys,
})
}
fn remove_command(name: &str, no_updatekeys: bool) -> RecipientCommand {
RecipientCommand::Remove(RecipientRemoveArgs {
name_pos: None,
name: Some(name.to_string()),
no_updatekeys,
})
}
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;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn set_env(key: &str, value: Option<&Path>) {
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
#[test]
#[serial]
fn keygen_generates_and_prints_enclave_identity() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let fake_pubkey = "age1se1qg8vwwqhztnh3vpt2nf2xwn7famktxlmp0nmkfltp8lkvzp8nafkqleh258";
let record = dir.path().join("plugin-args.log");
let plugin = dir.path().join("age-plugin-se");
write_script(
&plugin,
&format!(
"echo \"$@\" >> '{record}'\n\
cat <<'EOF'\n# public key: {fake_pubkey}\nAGE-PLUGIN-SE-1QFAKEIDENTITY\nEOF\n",
record = record.display()
),
);
let output = assert_cmd::Command::cargo_bin("sopsy")
.unwrap()
.env("SOPSY_AGE_PLUGIN_SE_BIN", &plugin)
.current_dir(dir.path())
.args([
"recipient",
"keygen",
"--",
"--access-control=any-biometry-or-passcode",
])
.output()
.unwrap();
assert!(output.status.success(), "keygen should exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(fake_pubkey),
"keygen should print the public key; got:\n{stdout}"
);
let logged = std::fs::read_to_string(&record).unwrap();
assert!(
logged.contains("keygen") && logged.contains("--access-control=any-biometry-or-passcode"),
"keygen should forward age flags to age-plugin-se; got:\n{logged}"
);
restore_cwd(&original);
}
#[test]
#[serial]
fn break_glass_generates_registers_and_deletes_local_files() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, key_a, _key_b) = setup_repo(dir.path());
set_age_key_file(Some(&key_a.file));
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
let out_prefix = repo.join("break-glass-key");
recipient::run(
&test_ui(),
&RecipientCommand::BreakGlass(RecipientBreakGlassArgs {
output: out_prefix.clone(),
name: None,
force: false,
no_updatekeys: false,
}),
)
.expect("break-glass should succeed");
assert!(
!with_suffix_test(&out_prefix, "private").exists(),
"private key file must be deleted"
);
assert!(
!with_suffix_test(&out_prefix, "public").exists(),
"public key file must be deleted"
);
let config = Config::load_from_dir(&repo).unwrap();
let bg = config
.recipient("break-glass")
.expect("break-glass recipient should be recorded");
assert!(bg.break_glass, "recipient must be marked break-glass");
assert!(
bg.public_key.starts_with("age1"),
"a real age public key should be recorded; got {}",
bg.public_key
);
let sops_yaml = std::fs::read_to_string(repo.join(".sops.yaml")).unwrap();
assert!(
sops_yaml.contains(&bg.public_key),
"break-glass key should be in .sops.yaml"
);
let decrypted = sops::decrypt(&repo.join(".env.encrypted"), FileType::Dotenv).unwrap();
assert!(decrypted.contains("FOO=bar"), "key A should still decrypt");
set_env("SOPSY_ASSUME_YES", None);
set_age_key_file(None);
restore_cwd(&original);
}
#[test]
#[serial]
fn break_glass_non_interactive_without_optin_fails_before_writing() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, _key_a, _key_b) = setup_repo(dir.path());
let out_prefix = repo.join("bg");
let err = recipient::run(
&test_ui(),
&RecipientCommand::BreakGlass(RecipientBreakGlassArgs {
output: out_prefix.clone(),
name: None,
force: false,
no_updatekeys: false,
}),
)
.expect_err("break-glass must refuse to run non-interactively");
assert!(matches!(err, Error::NonInteractive { .. }));
assert!(!with_suffix_test(&out_prefix, "private").exists());
assert!(!with_suffix_test(&out_prefix, "public").exists());
let config = Config::load_from_dir(&repo).unwrap();
assert!(config.recipient("break-glass").is_none());
restore_cwd(&original);
}
fn sopsy_with_pending_bob(bob_extra: &str) -> String {
format!(
"recipients:\n - name: alice\n public_key: age1alice\n \
- name: bob\n public_key: age1bob\n state: pending\n{bob_extra}"
)
}
#[test]
#[serial]
fn join_empty_name_rejected() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = join::run(
&test_ui(),
&JoinArgs {
name: " ".into(),
sopsy_file: None,
public_key: Some("age1x".into()),
age_args: vec![],
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("must not be empty")));
});
}
#[test]
#[serial]
fn join_rejects_duplicate_pending_request() {
with_repo(|dir| {
flow_repo(
dir,
Some(&sopsy_with_pending_bob("")),
Some(SOPS_ALICE_ONLY),
);
let err = join::run(
&test_ui(),
&JoinArgs {
name: "bob".into(),
sopsy_file: None,
public_key: Some("age1bobnew".into()),
age_args: vec![],
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("pending request")));
});
}
#[test]
#[serial]
fn join_rejects_duplicate_public_key() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = join::run(
&test_ui(),
&JoinArgs {
name: "carol".into(),
sopsy_file: None,
public_key: Some("age1alice".into()),
age_args: vec![],
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("already registered")));
});
}
#[test]
#[serial]
fn join_with_sopsy_file_targets_custom_path() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("custom-sopsy.yml");
std::fs::write(
&file,
"recipients:\n - name: alice\n public_key: age1alice\n",
)
.unwrap();
join::run(
&test_ui(),
&JoinArgs {
name: "bob".into(),
sopsy_file: Some(file.clone()),
public_key: Some("age1bobkey".into()),
age_args: vec![],
},
)
.expect("join --sopsy-file should succeed");
let cfg = Config::load(&file).unwrap();
let bob = cfg.recipient("bob").unwrap();
assert!(bob.is_pending());
assert!(bob.requested_at.is_some());
}
#[test]
fn join_with_missing_sopsy_file_errors() {
let err = join::run(
&test_ui(),
&JoinArgs {
name: "bob".into(),
sopsy_file: Some("/nonexistent/dir/custom.yml".into()),
public_key: Some("age1bobkey".into()),
age_args: vec![],
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("not found")));
}
#[test]
#[serial]
fn join_generates_enclave_identity_with_fake_plugin() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
git_init(dir.path());
std::fs::write(
dir.path().join(".sopsy.yml"),
"recipients:\n - name: alice\n public_key: age1alice\n",
)
.unwrap();
let fake_pub = "age1se1qg8vwwqhztnh3vpt2nf2xwn7famktxlmp0nmkfltp8lkvzp8nafkqleh258";
let plugin = dir.path().join("age-plugin-se");
write_script(
&plugin,
&format!("cat <<'EOF'\n# public key: {fake_pub}\nAGE-PLUGIN-SE-1QFAKE\nEOF\n"),
);
std::env::set_current_dir(dir.path()).unwrap();
let output = assert_cmd::Command::cargo_bin("sopsy")
.unwrap()
.env("SOPSY_AGE_PLUGIN_SE_BIN", &plugin)
.current_dir(dir.path())
.args(["--non-interactive", "join", "newbie"])
.output()
.unwrap();
assert!(
output.status.success(),
"join (generate) should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let cfg = Config::load_from_dir(dir.path()).unwrap();
let m = cfg.recipient("newbie").expect("newbie recorded");
assert!(m.is_pending());
assert_eq!(m.public_key, fake_pub);
restore_cwd(&original);
}
#[test]
#[serial]
fn approve_proceeds_without_timestamp_and_warns_on_no_age_rule() {
with_repo(|dir| {
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
flow_repo(
dir,
Some(&sopsy_with_pending_bob("")),
Some("creation_rules:\n - path_regex: \\.enc$\n"),
);
approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.expect("approve should succeed");
let cfg = Config::load_from_dir(dir).unwrap();
assert_eq!(cfg.recipient("bob").unwrap().state, MemberState::Active);
set_env("SOPSY_ASSUME_YES", None);
});
}
#[test]
#[serial]
fn approve_handles_unparseable_and_future_timestamps() {
with_repo(|dir| {
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
flow_repo(
dir,
Some(&sopsy_with_pending_bob(" requested_at: not-a-date\n")),
Some(SOPS_ALICE_ONLY),
);
approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.expect("approve should proceed past a bad timestamp");
assert_eq!(
Config::load_from_dir(dir)
.unwrap()
.recipient("bob")
.unwrap()
.state,
MemberState::Active
);
set_env("SOPSY_ASSUME_YES", None);
});
with_repo(|dir| {
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
flow_repo(
dir,
Some(&sopsy_with_pending_bob(
" requested_at: 2999-01-01T00:00:00Z\n",
)),
Some(SOPS_ALICE_ONLY),
);
approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.expect("a future-dated request is fresh");
set_env("SOPSY_ASSUME_YES", None);
});
}
#[test]
#[serial]
fn approve_stale_request_with_force_proceeds() {
with_repo(|dir| {
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
flow_repo(
dir,
Some(&sopsy_with_pending_bob(
" requested_at: 2020-01-01T00:00:00Z\njoin_request_ttl: 1h\n",
)),
Some(SOPS_ALICE_ONLY),
);
approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: true,
no_updatekeys: false,
},
)
.expect("--force should approve a stale request");
assert_eq!(
Config::load_from_dir(dir)
.unwrap()
.recipient("bob")
.unwrap()
.state,
MemberState::Active
);
set_env("SOPSY_ASSUME_YES", None);
});
}
#[test]
#[serial]
fn approve_non_interactive_without_optin_errors_at_vouch() {
with_repo(|dir| {
set_env("SOPSY_ASSUME_YES", None); flow_repo(
dir,
Some(&sopsy_with_pending_bob("")),
Some(SOPS_ALICE_ONLY),
);
let err = approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.unwrap_err();
assert!(matches!(err, Error::NonInteractive { .. }));
assert_eq!(
Config::load_from_dir(dir)
.unwrap()
.recipient("bob")
.unwrap()
.state,
MemberState::Pending
);
});
}
#[test]
#[serial]
fn approve_rolls_back_when_reencryption_fails() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
flow_repo(
dir.path(),
Some(&sopsy_with_pending_bob("")),
Some(SOPS_ALICE_ONLY),
);
std::fs::write(dir.path().join("secret.enc"), "FOO=ENC[x]\n").unwrap();
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
let fake = dir.path().join("fake-sops.sh");
write_script(&fake, "echo 'cannot get data key' >&2\nexit 1\n");
unsafe {
std::env::set_var("SOPSY_SOPS_BIN", &fake);
}
let err = approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("rolled back")));
assert_eq!(
Config::load_from_dir(dir.path())
.unwrap()
.recipient("bob")
.unwrap()
.state,
MemberState::Pending
);
unsafe {
std::env::remove_var("SOPSY_SOPS_BIN");
}
set_env("SOPSY_ASSUME_YES", None);
restore_cwd(&original);
}
fn with_suffix_test(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".");
name.push(suffix);
PathBuf::from(name)
}
#[test]
#[serial]
fn join_records_pending_member_without_granting_access() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, _key_a, key_b) = setup_repo(dir.path());
join::run(
&test_ui(),
&JoinArgs {
name: "bob".into(),
sopsy_file: None,
public_key: Some(key_b.public.clone()),
age_args: vec![],
},
)
.expect("join should succeed");
let config = Config::load_from_dir(&repo).unwrap();
let bob = config.recipient("bob").expect("bob should be recorded");
assert_eq!(bob.state, MemberState::Pending);
assert!(bob.requested_at.is_some(), "join should stamp a timestamp");
let sops_yaml = std::fs::read_to_string(repo.join(".sops.yaml")).unwrap();
assert!(
!sops_yaml.contains(&key_b.public),
"pending key must not appear in .sops.yaml"
);
restore_cwd(&original);
}
#[test]
#[serial]
fn join_rejects_existing_member() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = join::run(
&test_ui(),
&JoinArgs {
name: "alice".into(),
sopsy_file: None,
public_key: Some("age1x".into()),
age_args: vec![],
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("already an active member")));
});
}
#[test]
#[serial]
fn approve_activates_pending_member_and_rekeys() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, key_a, key_b) = setup_repo(dir.path());
join::run(
&test_ui(),
&JoinArgs {
name: "bob".into(),
sopsy_file: None,
public_key: Some(key_b.public.clone()),
age_args: vec![],
},
)
.expect("join should succeed");
set_age_key_file(Some(&key_a.file));
set_env("SOPSY_ASSUME_YES", Some(Path::new("1")));
approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.expect("approve should succeed");
let config = Config::load_from_dir(&repo).unwrap();
let bob = config.recipient("bob").unwrap();
assert_eq!(bob.state, MemberState::Active);
assert!(
bob.requested_at.is_none(),
"timestamp is cleared on approval"
);
let sops_yaml = std::fs::read_to_string(repo.join(".sops.yaml")).unwrap();
assert!(
sops_yaml.contains(&key_b.public),
"approved key must be added to .sops.yaml"
);
set_age_key_file(Some(&key_b.file));
let plain = sops::decrypt(&repo.join(".env.encrypted"), FileType::Dotenv).unwrap();
assert!(
plain.contains("FOO=bar"),
"newly approved member should decrypt"
);
set_env("SOPSY_ASSUME_YES", None);
set_age_key_file(None);
restore_cwd(&original);
}
#[test]
#[serial]
fn approve_rejects_stale_request() {
with_repo(|dir| {
let sopsy = "recipients:\n - name: alice\n public_key: age1alice\n \
- name: bob\n public_key: age1bob\n state: pending\n \
requested_at: 2020-01-01T00:00:00Z\njoin_request_ttl: 1h\n";
flow_repo(dir, Some(sopsy), Some(SOPS_ALICE_ONLY));
let err = approve::run(
&test_ui(),
&ApproveArgs {
name: "bob".into(),
force: false,
no_updatekeys: false,
},
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("older than")));
let config = Config::load_from_dir(dir).unwrap();
assert_eq!(config.recipient("bob").unwrap().state, MemberState::Pending);
});
}
#[test]
#[serial]
fn approve_unknown_and_already_active_error() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let ghost = approve::run(
&test_ui(),
&ApproveArgs {
name: "ghost".into(),
force: false,
no_updatekeys: false,
},
)
.unwrap_err();
assert!(matches!(ghost, Error::Validation(m) if m.contains("no member named")));
let active = approve::run(
&test_ui(),
&ApproveArgs {
name: "alice".into(),
force: false,
no_updatekeys: false,
},
)
.unwrap_err();
assert!(matches!(active, Error::Validation(m) if m.contains("already an active member")));
});
}
#[test]
#[serial]
fn add_registers_recipient_and_rewraps_secrets() {
let original_cwd = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, key_a, key_b) = setup_repo(dir.path());
set_age_key_file(Some(&key_a.file));
recipient::run(
&test_ui(),
&add_command("second", &key_b.public, false, false),
)
.expect("recipient add should succeed");
let config = Config::load_from_dir(&repo).unwrap();
assert_eq!(
config.recipient("second").map(|r| r.public_key.as_str()),
Some(key_b.public.as_str()),
"`.sopsy.yml` should list `second` with key B"
);
let sops_yaml = std::fs::read_to_string(repo.join(".sops.yaml")).unwrap();
assert!(
sops_yaml.contains(&key_b.public),
"`.sops.yaml` should list key B; got:\n{sops_yaml}"
);
let encrypted = repo.join(".env.encrypted");
set_age_key_file(Some(&key_b.file));
let with_b = sops::decrypt(&encrypted, FileType::Dotenv).unwrap();
assert!(
with_b.contains("FOO=bar"),
"key B should decrypt; got {with_b}"
);
set_age_key_file(Some(&key_a.file));
let with_a = sops::decrypt(&encrypted, FileType::Dotenv).unwrap();
assert!(
with_a.contains("FOO=bar"),
"key A should still decrypt; got {with_a}"
);
set_age_key_file(None);
restore_cwd(&original_cwd);
}
#[test]
#[serial]
fn remove_revokes_recipient_and_rewraps_secrets() {
let original_cwd = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, key_a, key_b) = setup_repo(dir.path());
set_age_key_file(Some(&key_a.file));
recipient::run(
&test_ui(),
&add_command("second", &key_b.public, false, false),
)
.expect("recipient add should succeed");
set_age_key_file(Some(&key_a.file));
recipient::run(&test_ui(), &remove_command("second", false))
.expect("recipient remove should succeed");
let config = Config::load_from_dir(&repo).unwrap();
assert!(
config.recipient("second").is_none(),
"`second` should be removed"
);
let sops_yaml = std::fs::read_to_string(repo.join(".sops.yaml")).unwrap();
assert!(
!sops_yaml.contains(&key_b.public),
"`.sops.yaml` should no longer list key B; got:\n{sops_yaml}"
);
let encrypted = repo.join(".env.encrypted");
set_age_key_file(Some(&key_b.file));
assert!(
sops::decrypt(&encrypted, FileType::Dotenv).is_err(),
"key B must no longer decrypt the secret"
);
set_age_key_file(Some(&key_a.file));
let with_a = sops::decrypt(&encrypted, FileType::Dotenv).unwrap();
assert!(
with_a.contains("FOO=bar"),
"key A should still decrypt; got {with_a}"
);
set_age_key_file(None);
restore_cwd(&original_cwd);
}
#[test]
#[serial]
fn list_prints_recipient_names() {
let original_cwd = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (repo, key_a, key_b) = setup_repo(dir.path());
set_age_key_file(Some(&key_a.file));
recipient::run(
&test_ui(),
&add_command("second", &key_b.public, false, false),
)
.expect("recipient add should succeed");
set_age_key_file(None);
restore_cwd(&original_cwd);
let output = assert_cmd::Command::cargo_bin("sopsy")
.unwrap()
.args(["recipient", "list"])
.current_dir(&repo)
.output()
.unwrap();
assert!(output.status.success(), "recipient list should exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("original"),
"list should show `original`; got:\n{stdout}"
);
assert!(
stdout.contains("second"),
"list should show `second`; got:\n{stdout}"
);
}
#[test]
#[serial]
fn no_updatekeys_skips_reencryption() {
let original_cwd = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (a_pub, _a_file) = generate_age_key(dir.path(), "keyA.txt");
git_init(dir.path());
write_sops_yaml(dir.path(), &a_pub);
write_sopsy_yml(dir.path(), &a_pub);
std::fs::write(dir.path().join(".env.encrypted"), "FOO=ENC[fake]\n").unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let marker = dir.path().join("sops-invoked.log");
let fake = dir.path().join("fake-sops.sh");
std::fs::write(
&fake,
format!("#!/bin/sh\necho \"$@\" >> {:?}\n", marker.to_string_lossy()),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
unsafe {
std::env::set_var("SOPSY_SOPS_BIN", &fake);
}
let fake_key = "age1fakekeyfortestingxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
recipient::run(&test_ui(), &add_command("second", fake_key, false, true))
.expect("recipient add (--no-updatekeys) should succeed");
assert!(
!marker.exists(),
"`sops updatekeys` must not run with --no-updatekeys"
);
let config = Config::load_from_dir(dir.path()).unwrap();
assert!(
config.recipient("second").is_some(),
"`second` should be recorded"
);
let sops_yaml = std::fs::read_to_string(dir.path().join(".sops.yaml")).unwrap();
assert!(
sops_yaml.contains(fake_key),
"`.sops.yaml` should list the new key"
);
recipient::run(
&test_ui(),
&add_command(
"third",
"age1anotherfakexxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
false,
false,
),
)
.expect("recipient add should succeed");
assert!(
marker.exists(),
"`sops updatekeys` should run without --no-updatekeys"
);
unsafe {
std::env::remove_var("SOPSY_SOPS_BIN");
}
restore_cwd(&original_cwd);
}
#[test]
#[serial]
fn add_rolls_back_when_reencryption_fails() {
let original_cwd = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let (a_pub, _a_file) = generate_age_key(dir.path(), "keyA.txt");
git_init(dir.path());
write_sops_yaml(dir.path(), &a_pub);
write_sopsy_yml(dir.path(), &a_pub);
std::fs::write(dir.path().join(".env.encrypted"), "FOO=ENC[fake]\n").unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let fake = dir.path().join("fake-sops.sh");
std::fs::write(&fake, "#!/bin/sh\necho 'cannot get data key' >&2\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
unsafe {
std::env::set_var("SOPSY_SOPS_BIN", &fake);
}
let new_key = "age1rollbackkeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let err = recipient::run(&test_ui(), &add_command("second", new_key, false, false))
.expect_err("recipient add must fail when re-encryption fails");
let msg = err.to_string();
assert!(
msg.contains("SOPS_AGE_KEY_FILE") && msg.contains("rolled back"),
"error should guide the operator and mention rollback: {msg}"
);
let config = Config::load_from_dir(dir.path()).unwrap();
assert!(
config.recipient("second").is_none(),
"`second` must not remain in .sopsy.yml after rollback"
);
let sops_yaml = std::fs::read_to_string(dir.path().join(".sops.yaml")).unwrap();
assert!(
!sops_yaml.contains(new_key),
".sops.yaml must not list the new key after rollback"
);
unsafe {
std::env::remove_var("SOPSY_SOPS_BIN");
}
restore_cwd(&original_cwd);
}
const SOPSY_TWO: &str = "recipients:\n \
- name: alice\n public_key: age1alice\n break_glass: true\n \
- name: bob\n public_key: age1bob\n break_glass: false\n";
const SOPS_ALICE_ONLY: &str = "creation_rules:\n - path_regex: \\.enc$\n age: age1alice\n";
fn flow_repo(dir: &Path, sopsy_yml: Option<&str>, sops_yaml: Option<&str>) {
git_init(dir);
if let Some(body) = sopsy_yml {
std::fs::write(dir.join(".sopsy.yml"), body).unwrap();
}
if let Some(body) = sops_yaml {
std::fs::write(dir.join(".sops.yaml"), body).unwrap();
}
std::env::set_current_dir(dir).unwrap();
}
fn add_args(
name: Option<&str>,
key: Option<&str>,
break_glass: bool,
no_updatekeys: bool,
) -> RecipientCommand {
RecipientCommand::Add(RecipientAddArgs {
name_pos: None,
name: name.map(str::to_string),
public_key: key.map(str::to_string),
break_glass,
no_updatekeys,
})
}
fn remove_args(name: Option<&str>, no_updatekeys: bool) -> RecipientCommand {
RecipientCommand::Remove(RecipientRemoveArgs {
name_pos: None,
name: name.map(str::to_string),
no_updatekeys,
})
}
struct CwdGuard(PathBuf);
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
fn with_repo(f: impl FnOnce(&Path)) {
let _guard = CwdGuard(std::env::current_dir().unwrap());
let dir = TempDir::new().unwrap();
f(dir.path());
}
#[test]
#[serial]
fn add_without_sopsy_yml_is_friendly_error() {
with_repo(|dir| {
flow_repo(dir, None, Some(SOPS_ALICE_ONLY));
let err = recipient::run(&test_ui(), &add_args(Some("x"), Some("age1x"), false, true))
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("sopsy init")));
});
}
#[test]
#[serial]
fn add_with_invalid_sopsy_yml_propagates_parse_error() {
with_repo(|dir| {
flow_repo(
dir,
Some("recipients: [unterminated\n"),
Some(SOPS_ALICE_ONLY),
);
let err = recipient::run(&test_ui(), &add_args(Some("x"), Some("age1x"), false, true))
.unwrap_err();
assert!(matches!(err, Error::Parse { .. }));
});
}
#[test]
#[serial]
fn add_without_sops_yaml_is_friendly_error() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), None);
let err = recipient::run(&test_ui(), &add_args(Some("x"), Some("age1x"), false, true))
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains(".sops.yaml not found")));
});
}
#[test]
#[serial]
fn add_missing_name_non_interactive_errors() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err =
recipient::run(&test_ui(), &add_args(None, Some("age1x"), false, true)).unwrap_err();
assert!(matches!(err, Error::NonInteractive { .. }));
});
}
#[test]
#[serial]
fn add_missing_public_key_non_interactive_errors() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err =
recipient::run(&test_ui(), &add_args(Some("carol"), None, false, true)).unwrap_err();
assert!(matches!(err, Error::NonInteractive { .. }));
});
}
#[test]
#[serial]
fn add_blank_name_rejected() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(
&test_ui(),
&add_args(Some(" "), Some("age1x"), false, true),
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("name must not be empty")));
});
}
#[test]
#[serial]
fn add_blank_public_key_rejected() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(
&test_ui(),
&add_args(Some("carol"), Some(" "), false, true),
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("public key must not be empty")));
});
}
#[test]
#[serial]
fn add_duplicate_name_rejected() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(
&test_ui(),
&add_args(Some("alice"), Some("age1new"), false, true),
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("already exists")));
});
}
#[test]
#[serial]
fn add_duplicate_key_rejected() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(
&test_ui(),
&add_args(Some("carol"), Some("age1alice"), false, true),
)
.unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("already registered")));
});
}
#[test]
#[serial]
fn add_warns_when_no_age_rule_matches() {
with_repo(|dir| {
flow_repo(
dir,
Some(SOPSY_TWO),
Some("creation_rules:\n - path_regex: \\.enc$\n"),
);
recipient::run(
&test_ui(),
&add_args(Some("carol"), Some("age1carol"), false, true),
)
.expect("add should still record the recipient");
let config = Config::load_from_dir(dir).unwrap();
assert!(config.recipient("carol").is_some());
});
}
#[test]
#[serial]
fn add_break_glass_recipient_records_flag() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
recipient::run(
&test_ui(),
&add_args(Some("carol"), Some("age1carol"), true, true),
)
.expect("add --break-glass should succeed");
let config = Config::load_from_dir(dir).unwrap();
assert!(config.recipient("carol").unwrap().break_glass);
});
}
#[test]
#[serial]
fn remove_missing_name_with_no_recipients_errors() {
with_repo(|dir| {
flow_repo(dir, Some("recipients: []\n"), Some(SOPS_ALICE_ONLY));
let err = recipient::run(&test_ui(), &remove_args(None, true)).unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("no recipients to remove")));
});
}
#[test]
#[serial]
fn remove_missing_name_non_interactive_errors() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(&test_ui(), &remove_args(None, true)).unwrap_err();
assert!(matches!(err, Error::NonInteractive { .. }));
});
}
#[test]
#[serial]
fn remove_unknown_recipient_errors() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(&test_ui(), &remove_args(Some("ghost"), true)).unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("no recipient named")));
});
}
#[test]
#[serial]
fn remove_last_recipient_refused() {
with_repo(|dir| {
flow_repo(
dir,
Some("recipients:\n - name: solo\n public_key: age1solo\n break_glass: false\n"),
Some(SOPS_ALICE_ONLY),
);
let err = recipient::run(&test_ui(), &remove_args(Some("solo"), true)).unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("only remaining recipient")));
});
}
#[test]
#[serial]
fn remove_sole_break_glass_refused() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
let err = recipient::run(&test_ui(), &remove_args(Some("alice"), true)).unwrap_err();
assert!(matches!(err, Error::Validation(m) if m.contains("only break-glass recipient")));
});
}
#[test]
#[serial]
fn remove_warns_when_key_absent_from_sops_yaml() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
recipient::run(&test_ui(), &remove_args(Some("bob"), true))
.expect("removing bob should succeed");
let config = Config::load_from_dir(dir).unwrap();
assert!(config.recipient("bob").is_none());
});
}
#[test]
#[serial]
fn list_without_sopsy_yml_is_noop() {
with_repo(|dir| {
flow_repo(dir, None, None);
recipient::run(&test_ui(), &RecipientCommand::List).expect("list should succeed");
});
}
#[test]
#[serial]
fn list_with_invalid_sopsy_yml_errors() {
with_repo(|dir| {
flow_repo(dir, Some("recipients: [unterminated\n"), None);
let err = recipient::run(&test_ui(), &RecipientCommand::List).unwrap_err();
assert!(matches!(err, Error::Parse { .. }));
});
}
#[test]
#[serial]
fn list_with_no_recipients_is_noop() {
with_repo(|dir| {
flow_repo(dir, Some("recipients: []\n"), None);
recipient::run(&test_ui(), &RecipientCommand::List).expect("list should succeed");
});
}
#[test]
#[serial]
fn list_renders_plain_and_colored_tables() {
with_repo(|dir| {
let long_key = format!("age1{}", "z".repeat(60));
let body = format!(
"recipients:\n - name: alice\n public_key: {long_key}\n break_glass: true\n \
- name: bob\n public_key: age1bob\n break_glass: false\n"
);
flow_repo(dir, Some(&body), None);
recipient::run(&test_ui(), &RecipientCommand::List).expect("plain list");
recipient::run(&color_ui(), &RecipientCommand::List).expect("colored list");
});
}
#[test]
#[serial]
fn add_without_encrypted_files_reports_nothing_to_rekey() {
with_repo(|dir| {
flow_repo(dir, Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
recipient::run(
&test_ui(),
&add_args(Some("carol"), Some("age1carol"), false, false),
)
.expect("add should succeed with nothing to re-key");
let config = Config::load_from_dir(dir).unwrap();
assert!(config.recipient("carol").is_some());
});
}
#[test]
#[serial]
fn updatekeys_failure_surfaces_process_error() {
let original = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
flow_repo(dir.path(), Some(SOPSY_TWO), Some(SOPS_ALICE_ONLY));
std::fs::write(dir.path().join("secret.enc"), "FOO=ENC[x]\n").unwrap();
let fake = dir.path().join("fake-sops.sh");
std::fs::write(&fake, "#!/bin/sh\necho 'boom' >&2\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
unsafe {
std::env::set_var("SOPSY_SOPS_BIN", &fake);
}
let err = recipient::run(
&test_ui(),
&add_args(Some("carol"), Some("age1carol"), false, false),
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, Error::Validation(_)),
"updatekeys failure should surface as a rollback validation error: {msg}"
);
assert!(
msg.contains("rolled back") && msg.contains("boom"),
"error should mention rollback and include the sops error: {msg}"
);
let config = Config::load_from_dir(dir.path()).unwrap();
assert!(
config.recipient("carol").is_none(),
"carol must be rolled back out of .sopsy.yml"
);
unsafe {
std::env::remove_var("SOPSY_SOPS_BIN");
}
restore_cwd(&original);
}