use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
const MASTER_PASSWORD: &str = "Str0ng!Passw0rd!";
struct TestEnv {
_root: TempDir,
project: PathBuf,
home: PathBuf,
}
impl TestEnv {
fn new() -> Self {
let root = TempDir::new().expect("create temp dir");
let canonical = root.path().canonicalize().expect("canonicalize temp dir");
let project = canonical.join("project");
let home = canonical.join("home");
fs::create_dir(&project).expect("create project dir");
fs::create_dir(&home).expect("create home dir");
Self {
_root: root,
project,
home,
}
}
fn dl(&self) -> Command {
self.dl_as(&self.home)
}
fn dl_as(&self, home: &Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_dl"));
cmd.current_dir(&self.project)
.env("DOTLOCK_HOME", home)
.env("HOME", home)
.env("NO_COLOR", "1");
cmd
}
fn init_vault(&self) {
self.dl()
.args(["init", "--password-stdin"])
.write_stdin(format!("{MASTER_PASSWORD}\n"))
.assert()
.success();
assert!(
self.project.join(".lock/vault.toml").exists(),
"`dl init` must create .lock/vault.toml"
);
assert!(
self.project.join(".lock/secrets.lock").exists(),
"`dl init` must create .lock/secrets.lock"
);
}
fn lock_path(&self, name: &str) -> PathBuf {
self.project.join(".lock").join(name)
}
}
#[test]
fn version_flag_reports_package_version() {
let env = TestEnv::new();
env.dl()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn commands_fail_cleanly_before_init() {
let env = TestEnv::new();
for args in [
vec!["list"],
vec!["get", "FOO"],
vec!["set", "FOO", "bar"],
vec!["unset", "FOO"],
vec!["export", ".env.out"],
vec!["run", "--", "true"],
vec!["reconcile"],
] {
env.dl()
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("not initialized"))
.stderr(predicate::str::contains("dl init"));
}
assert!(
!env.project.join(".env.out").exists(),
"export must not write a file before init"
);
}
#[test]
fn init_without_a_tty_fails_with_actionable_error() {
let env = TestEnv::new();
env.dl()
.arg("init")
.write_stdin(format!("{MASTER_PASSWORD}\n{MASTER_PASSWORD}\n"))
.assert()
.failure()
.stderr(predicate::str::contains("no TTY"))
.stderr(predicate::str::contains("DOTLOCK_MASTER_PASSWORD"))
.stderr(predicate::str::contains("--password-stdin"))
.stderr(predicate::str::contains("panicked").not());
assert!(!env.project.join(".lock/vault.toml").exists());
}
#[test]
fn non_interactive_unlock_via_env_var_and_password_file() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl().arg("lock").assert().success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl().arg("lock").assert().success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", "Wr0ng!Passw0rd!!")
.args(["get", "FOO"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid master password"));
let password_file = env.home.join("master.pw");
fs::write(&password_file, format!("{MASTER_PASSWORD}\n")).expect("write password file");
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO", "--password-file"])
.arg(&password_file)
.assert()
.success()
.stdout("bar-value\n");
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO", "--password-stdin"])
.write_stdin(format!("{MASTER_PASSWORD}\n"))
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn json_output_for_list_get_share_and_provider() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let out = env
.dl()
.args(["list", "--json"])
.output()
.expect("run list");
assert!(out.status.success());
let listed: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("list --json must be valid JSON");
let items = listed.as_array().expect("list --json must be an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "FOO");
assert!(items[0]["id"].as_str().is_some_and(|id| !id.is_empty()));
assert!(items[0].get("value").is_none(), "list must not leak values");
let out = env
.dl()
.args(["get", "FOO", "--json"])
.output()
.expect("run get");
assert!(out.status.success());
let got: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("get --json must be valid JSON");
assert_eq!(got["name"], "FOO");
assert_eq!(got["value"], "bar-value");
assert_eq!(got["id"], items[0]["id"]);
let out = env
.dl()
.args(["share", "list", "--json"])
.output()
.expect("run share list");
assert!(out.status.success());
let recipients: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("share list --json must be valid JSON");
assert_eq!(recipients, serde_json::json!([]));
let out = env
.dl()
.args(["provider", "list", "--json"])
.output()
.expect("run provider list");
assert!(out.status.success());
let providers: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("provider list --json must be valid JSON");
assert!(providers.is_array());
}
#[test]
fn secret_lifecycle_set_get_list_export_run_unset() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success()
.stdout(predicate::str::contains("secret FOO saved"));
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("FOO"))
.stdout(predicate::str::contains("total:"))
.stdout(predicate::str::contains("1 secret"));
env.dl()
.args([
"run",
"--",
"sh",
"-c",
"printf 'child sees %s\\n' \"$FOO\"",
])
.assert()
.success()
.stdout(predicate::str::contains("child sees bar-value"));
env.dl()
.args(["export", ".env.out"])
.assert()
.success()
.stdout(predicate::str::contains("exported 1 variable"));
let exported = fs::read_to_string(env.project.join(".env.out")).expect("read exported env");
assert!(
exported.contains("FOO=bar-value"),
"exported file must contain the secret: {exported}"
);
env.dl()
.args(["unset", "--yes", "FOO"])
.assert()
.success()
.stdout(predicate::str::contains("secret FOO removed"));
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
env.dl()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("no secrets stored"));
}
#[test]
fn set_reads_secret_value_from_stdin() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "PIPED", "--stdin"])
.write_stdin("piped-value\n")
.assert()
.success()
.stdout(predicate::str::contains("secret PIPED saved"));
env.dl()
.args(["get", "PIPED"])
.assert()
.success()
.stdout("piped-value\n");
env.dl()
.args(["set", "EMPTY", "--stdin"])
.write_stdin("")
.assert()
.failure()
.stderr(predicate::str::contains("no secret value received"));
}
#[test]
fn pending_merge_marker_blocks_access_until_reconciled() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let marker = env.lock_path("pending-merge");
fs::write(&marker, "version = 1\ncreated_at = 0\n").expect("write pending-merge marker");
for args in [vec!["list"], vec!["get", "FOO"], vec!["run", "--", "true"]] {
env.dl()
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("unreconciled merge"))
.stderr(predicate::str::contains("dl reconcile"));
}
env.dl()
.arg("reconcile")
.write_stdin("n\n")
.assert()
.code(130)
.stdout(predicate::str::contains("merge left unreconciled"));
assert!(marker.exists(), "declined reconcile must keep the marker");
fs::remove_file(&marker).expect("remove marker");
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn tampered_vault_metadata_is_refused() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let vault = env.lock_path("vault.toml");
let content = fs::read_to_string(&vault).expect("read vault.toml");
let tampered = content.replace("kek_writes_since_rotate = 1", "kek_writes_since_rotate = 9");
assert_ne!(content, tampered, "fixture must actually change the field");
fs::write(&vault, &tampered).expect("write tampered vault.toml");
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stdout(predicate::str::contains("bar-value").not())
.stderr(predicate::str::contains("failed authentication"));
}
#[test]
fn restored_older_vault_pair_is_refused_as_rollback() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "old-value"])
.assert()
.success();
let vault = env.lock_path("vault.toml");
let secrets = env.lock_path("secrets.lock");
let old_vault = fs::read(&vault).expect("read vault.toml");
let old_secrets = fs::read(&secrets).expect("read secrets.lock");
env.dl()
.args(["set", "FOO", "new-value"])
.assert()
.success();
fs::write(&vault, &old_vault).expect("restore vault.toml");
fs::write(&secrets, &old_secrets).expect("restore secrets.lock");
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stdout(predicate::str::contains("old-value").not())
.stderr(predicate::str::contains("rollback"));
env.dl()
.env("DOTLOCK_ALLOW_VAULT_ROLLBACK", "1")
.args(["get", "FOO"])
.assert()
.success()
.stdout(predicate::str::contains("old-value"));
}
#[test]
fn exec_shell_form_and_env_file_fallback() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "MY_SECRET", "vault-value"])
.assert()
.success();
env.dl()
.args(["exec", r#"printf 'exec sees %s\n' "$MY_SECRET""#])
.assert()
.success()
.stdout(predicate::str::contains("exec sees vault-value"));
env.dl()
.args(["exec", r#"true && printf 'chained %s\n' "$MY_SECRET""#])
.assert()
.success()
.stdout(predicate::str::contains("chained vault-value"));
let env_file = env.project.join(".env.extra");
fs::write(
&env_file,
"EXTRA_VAR=from-env-file\nMY_SECRET=plaintext-should-lose\n",
)
.expect("write env file");
env.dl()
.args(["exec", "--env-file", ".env.extra"])
.arg(r#"printf '%s %s\n' "$EXTRA_VAR" "$MY_SECRET""#)
.assert()
.success()
.stdout(predicate::str::contains("from-env-file vault-value"))
.stdout(predicate::str::contains("plaintext-should-lose").not());
env.dl()
.args([
"run",
"--env-file",
".env.extra",
"--",
"sh",
"-c",
r#"printf 'run %s %s\n' "$EXTRA_VAR" "$MY_SECRET""#,
])
.assert()
.success()
.stdout(predicate::str::contains("run from-env-file vault-value"));
}
#[test]
fn rotate_if_due_noops_when_not_due_and_rotates_when_due() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl().arg("lock").assert().success();
env.dl()
.args(["rotate", "--if-due"])
.assert()
.success()
.stdout(predicate::str::contains("rotation not due"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["config", "set", "rotate_max_age_days", "30"])
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["rotate", "--if-due"])
.assert()
.success()
.stdout(predicate::str::contains("rotation due"))
.stdout(predicate::str::contains("rotated"));
env.dl()
.args(["rotate", "--if-due"])
.assert()
.success()
.stdout(predicate::str::contains("rotation not due"));
env.dl().arg("lock").assert().success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn repair_recovers_hash_stale_vault() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let secrets = env.lock_path("secrets.lock");
let mut content = fs::read_to_string(&secrets).expect("read secrets.lock");
content.push_str("# restored from a partial backup\n");
fs::write(&secrets, &content).expect("write stale secrets.lock");
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stderr(predicate::str::contains("modified outside"));
let vault_before = fs::read(env.lock_path("vault.toml")).expect("vault bytes");
let secrets_before = fs::read(&secrets).expect("secrets bytes");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["repair", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("STALE"))
.stdout(predicate::str::contains("dry run"));
assert_eq!(
fs::read(env.lock_path("vault.toml")).expect("vault bytes"),
vault_before
);
assert_eq!(fs::read(&secrets).expect("secrets bytes"), secrets_before);
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["repair", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("integrity hash recomputed"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl()
.args(["audit", "show"])
.assert()
.success()
.stdout(predicate::str::contains("repair"));
}
#[test]
fn repair_prunes_only_irrecoverable_records() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "KEEP", "keep-value"])
.assert()
.success();
env.dl()
.args(["set", "DOOMED", "doomed-value"])
.assert()
.success();
let out = env.dl().args(["list", "--json"]).output().expect("list");
let listed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let doomed_id = listed
.as_array()
.expect("array")
.iter()
.find(|item| item["name"] == "DOOMED")
.expect("doomed listed")["id"]
.as_str()
.expect("id")
.to_string();
let vault = env.lock_path("vault.toml");
let content = fs::read_to_string(&vault).expect("read vault.toml");
let filtered: String = content
.lines()
.filter(|line| !line.contains(&doomed_id) && !line.starts_with("metadata_mac_b64"))
.map(|line| format!("{line}\n"))
.collect();
assert_ne!(content, filtered, "fixture must drop the SDK wrapping");
fs::write(&vault, &filtered).expect("write orphaned vault.toml");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["repair", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("IRRECOVERABLE"))
.stdout(predicate::str::contains(&doomed_id));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["repair", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("irrecoverable"))
.stderr(predicate::str::contains("--prune"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["repair", "--prune", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("removed 1 irrecoverable record"))
.stdout(predicate::str::contains("DOOMED"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "KEEP"])
.assert()
.success()
.stdout("keep-value\n");
env.dl()
.args(["get", "DOOMED"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn repair_without_valid_password_changes_nothing() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let secrets = env.lock_path("secrets.lock");
let mut content = fs::read_to_string(&secrets).expect("read secrets.lock");
content.push_str("# out-of-band edit\n");
fs::write(&secrets, &content).expect("write stale secrets.lock");
let vault_before = fs::read(env.lock_path("vault.toml")).expect("vault bytes");
let secrets_before = fs::read(&secrets).expect("secrets bytes");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", "Wr0ng!Passw0rd!!")
.args(["repair", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid master password"));
assert_eq!(
fs::read(env.lock_path("vault.toml")).expect("vault bytes"),
vault_before
);
assert_eq!(fs::read(&secrets).expect("secrets bytes"), secrets_before);
}
#[test]
fn tampered_secrets_file_is_refused() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
let secrets = env.lock_path("secrets.lock");
let mut content = fs::read_to_string(&secrets).expect("read secrets.lock");
content.push_str("# tampered outside dotlock\n");
fs::write(&secrets, &content).expect("write tampered secrets.lock");
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stdout(predicate::str::contains("bar-value").not())
.stderr(predicate::str::contains("error:"));
}
#[test]
fn environments_are_isolated_and_selectable() {
const STAGING_PASSWORD: &str = "Sta9ing!Passw0rd";
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "ONLY_DEFAULT", "default-value"])
.assert()
.success();
env.dl()
.args(["env", "add", "staging", "--password-stdin"])
.write_stdin(format!("{STAGING_PASSWORD}\n"))
.assert()
.success();
assert!(env.project.join(".lock/envs/staging/vault.toml").exists());
assert!(env.project.join(".lock/envs/staging/secrets.lock").exists());
assert!(env.lock_path("vault.toml").exists());
assert!(env.lock_path("secrets.lock").exists());
env.dl()
.args(["env", "add", "staging"])
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
env.dl()
.args(["env", "add", "../evil"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", STAGING_PASSWORD)
.args(["--env", "staging", "set", "API_KEY", "staging-value"])
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", STAGING_PASSWORD)
.args(["--env", "staging", "get", "API_KEY"])
.assert()
.success()
.stdout("staging-value\n");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "API_KEY"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", STAGING_PASSWORD)
.args(["--env", "staging", "get", "ONLY_DEFAULT"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
env.dl()
.args(["--env", "staging", "lock"])
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["--env", "staging", "get", "API_KEY"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid master password"));
env.dl()
.env("DOTLOCK_ENV", "staging")
.env("DOTLOCK_MASTER_PASSWORD", STAGING_PASSWORD)
.args(["get", "API_KEY"])
.assert()
.success()
.stdout("staging-value\n");
let out = env
.dl()
.args(["env", "list", "--json"])
.output()
.expect("run env list");
assert!(out.status.success());
let parsed: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("env list emits valid JSON");
let names: Vec<&str> = parsed
.as_array()
.expect("array")
.iter()
.map(|item| item["name"].as_str().expect("name"))
.collect();
assert_eq!(names, vec!["default", "staging"]);
assert_eq!(parsed[0]["active"], serde_json::json!(true));
assert_eq!(parsed[1]["active"], serde_json::json!(false));
env.dl().args(["env", "use", "staging"]).assert().success();
assert_eq!(
fs::read_to_string(env.lock_path("env"))
.expect("selection file")
.trim(),
"staging"
);
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", STAGING_PASSWORD)
.args(["get", "API_KEY"])
.assert()
.success()
.stdout("staging-value\n");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["--env", "default", "get", "ONLY_DEFAULT"])
.assert()
.success()
.stdout("default-value\n");
env.dl()
.args(["--env", "missing", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("not initialized"))
.stderr(predicate::str::contains("dl env add"));
env.dl().args(["env", "use", "default"]).assert().success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "ONLY_DEFAULT"])
.assert()
.success()
.stdout("default-value\n");
}
#[test]
fn destructive_ops_require_yes_without_a_tty() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["unset", "FOO"])
.assert()
.failure()
.stderr(predicate::str::contains("confirmation required"))
.stderr(predicate::str::contains("--yes"));
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
for rotate in ["project-key", "kek", "master-password"] {
env.dl()
.args(["rotate", rotate])
.assert()
.failure()
.stderr(predicate::str::contains("confirmation required"))
.stderr(predicate::str::contains("--yes"));
}
env.dl()
.args(["share", "revoke", "anyone"])
.assert()
.failure()
.stderr(predicate::str::contains("confirmation required"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["rotate", "project-key", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("rotated"));
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl()
.args(["unset", "--yes", "FOO"])
.assert()
.success()
.stdout(predicate::str::contains("secret FOO removed"));
env.dl()
.args(["rotate", "--if-due"])
.assert()
.success()
.stdout(predicate::str::contains("rotation not due"));
}
#[test]
fn env_remove_deletes_named_environment_and_refuses_default() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["env", "add", "staging", "--password-stdin"])
.write_stdin(format!("{MASTER_PASSWORD}\n"))
.assert()
.success();
let staging_dir = env.lock_path("envs").join("staging");
assert!(staging_dir.join("vault.toml").exists());
env.dl()
.args(["env", "remove", "staging"])
.assert()
.failure()
.stderr(predicate::str::contains("confirmation required"))
.stderr(predicate::str::contains("--yes"));
assert!(staging_dir.join("vault.toml").exists());
env.dl()
.args(["env", "remove", "default", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains(
"default environment cannot be removed",
));
assert!(env.lock_path("vault.toml").exists());
env.dl()
.args(["env", "remove", "missing", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("not initialized"));
env.dl()
.args(["env", "remove", "staging", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains(
"permanently deletes environment staging",
))
.stdout(predicate::str::contains("environment staging removed"));
assert!(!staging_dir.exists());
env.dl()
.args(["env", "list"])
.assert()
.success()
.stdout(predicate::str::contains("staging").not());
}
#[test]
fn env_remove_resets_persisted_selection() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["env", "add", "staging", "--password-stdin"])
.write_stdin(format!("{MASTER_PASSWORD}\n"))
.assert()
.success();
env.dl().args(["env", "use", "staging"]).assert().success();
assert!(env.lock_path("env").exists());
env.dl()
.args(["env", "remove", "staging", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("selection reset"));
assert!(
!env.lock_path("env").exists(),
"the dangling selection file must be removed"
);
env.dl()
.args(["env", "list"])
.assert()
.success()
.stdout(predicate::str::contains("default"));
}
#[test]
fn export_warns_about_plaintext_and_missing_gitignore_coverage() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["export", ".env.plain"])
.assert()
.success()
.stderr(predicate::str::contains("PLAINTEXT secrets"))
.stderr(predicate::str::contains("gitignore").not());
let git_init = std::process::Command::new("git")
.arg("init")
.current_dir(&env.project)
.output()
.expect("git init");
assert!(git_init.status.success());
env.dl()
.args(["export", ".env.out"])
.assert()
.success()
.stderr(predicate::str::contains("PLAINTEXT secrets"))
.stderr(predicate::str::contains("NOT covered by .gitignore"))
.stderr(predicate::str::contains(
"add `.env.out` to your .gitignore",
));
assert!(
!env.project.join(".gitignore").exists(),
"export must not silently create/modify .gitignore without a TTY confirmation"
);
fs::write(env.project.join(".gitignore"), ".env.covered\n").expect("write .gitignore");
env.dl()
.args(["export", ".env.covered"])
.assert()
.success()
.stderr(predicate::str::contains("PLAINTEXT secrets"))
.stderr(predicate::str::contains("NOT covered").not());
}
#[test]
fn get_piped_output_stays_bare_with_and_without_reveal() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl()
.args(["get", "FOO", "--reveal"])
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn fresh_shared_setup_uses_modern_crypto_with_zero_rsa() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["cert", "init", "--plain"])
.assert()
.success();
env.dl()
.args(["cert", "show"])
.assert()
.success()
.stdout(predicate::str::contains("ed25519"))
.stdout(predicate::str::contains("passphrase: no"));
let pubkey = env.project.join("me.pub.pem");
env.dl()
.args(["cert", "export-pub"])
.arg(&pubkey)
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["share", "grant", "--pubkey"])
.arg(&pubkey)
.args(["--label", "me"])
.assert()
.success();
let vault = fs::read_to_string(env.lock_path("vault.toml")).expect("read vault.toml");
assert!(
vault.contains("x25519-sealedbox"),
"recipient must be wrapped with the X25519 sealed box:\n{vault}"
);
assert!(
!vault.contains("rsa-oaep-sha256"),
"a fresh setup must contain zero RSA material:\n{vault}"
);
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn shared_vault_with_encrypted_identity_unlocks_non_interactively() {
const IDENTITY_PASSPHRASE: &str = "1d3ntity-Pass!";
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["cert", "init", "--password-stdin"])
.write_stdin(format!("{IDENTITY_PASSPHRASE}\n"))
.assert()
.success();
env.dl()
.args(["cert", "show"])
.assert()
.success()
.stdout(predicate::str::contains("passphrase: yes"));
let pubkey = env.project.join("me.pub.pem");
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["cert", "export-pub"])
.arg(&pubkey)
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["share", "grant", "--pubkey"])
.arg(&pubkey)
.args(["--label", "me"])
.assert()
.success();
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO"])
.assert()
.failure()
.stderr(predicate::str::contains("--password-stdin"));
env.dl()
.args(["get", "FOO", "--password-stdin"])
.write_stdin(format!("{IDENTITY_PASSPHRASE}\n"))
.assert()
.success()
.stdout("bar-value\n");
env.dl().arg("lock").assert().success();
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO", "--password-stdin"])
.write_stdin("wrong-passphrase\n")
.assert()
.failure();
}
#[test]
fn cert_passwd_removes_and_changes_passphrase_preserving_shared_access() {
const IDENTITY_PASSPHRASE: &str = "0ld-Pass!";
const NEW_PASSPHRASE: &str = "N3w-Pass!";
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "FOO", "bar-value"])
.assert()
.success();
env.dl()
.args(["cert", "init", "--password-stdin"])
.write_stdin(format!("{IDENTITY_PASSPHRASE}\n"))
.assert()
.success();
let pubkey = env.project.join("me.pub.pem");
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["cert", "export-pub"])
.arg(&pubkey)
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["share", "grant", "--pubkey"])
.arg(&pubkey)
.args(["--label", "me"])
.assert()
.success();
let out = env
.dl()
.args(["share", "list", "--json"])
.output()
.expect("run share list");
let recipients: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("share list --json must be valid JSON");
let fingerprint = recipients.as_array().expect("recipients")[0]["fingerprint"]
.as_str()
.expect("fingerprint string")
.to_string();
env.dl().arg("lock").assert().success();
env.dl().args(["get", "FOO"]).assert().failure();
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["cert", "passwd", "--remove"])
.assert()
.success()
.stdout(predicate::str::contains("identity passphrase removed"))
.stdout(predicate::str::contains("fingerprint unchanged"))
.stdout(predicate::str::contains(&fingerprint));
env.dl()
.args(["cert", "show"])
.assert()
.success()
.stdout(predicate::str::contains("passphrase: no"))
.stdout(predicate::str::contains(&fingerprint));
let vault = fs::read_to_string(env.lock_path("vault.toml")).expect("read vault.toml");
assert!(
vault.contains(&fingerprint),
"the recipient entry must still match the (unchanged) fingerprint:\n{vault}"
);
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
env.dl().args(["run", "--", "true"]).assert().success();
env.dl()
.args(["cert", "passwd", "--password-stdin"])
.write_stdin(format!("{NEW_PASSPHRASE}\n"))
.assert()
.success()
.stdout(predicate::str::contains("identity passphrase updated"))
.stdout(predicate::str::contains(&fingerprint));
env.dl()
.args(["cert", "show"])
.assert()
.success()
.stdout(predicate::str::contains("passphrase: yes"))
.stdout(predicate::str::contains(&fingerprint));
env.dl().arg("lock").assert().success();
env.dl().args(["get", "FOO"]).assert().failure();
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", IDENTITY_PASSPHRASE)
.args(["get", "FOO"])
.assert()
.failure();
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", NEW_PASSPHRASE)
.args(["get", "FOO"])
.assert()
.success()
.stdout("bar-value\n");
}
#[test]
fn cert_init_rejects_an_empty_identity_passphrase() {
let env = TestEnv::new();
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", "")
.args(["cert", "init"])
.assert()
.failure()
.stderr(predicate::str::contains("empty identity passphrase"))
.stderr(predicate::str::contains("--plain"))
.stderr(predicate::str::contains("panicked").not());
env.dl()
.args(["cert", "show"])
.assert()
.failure()
.stderr(predicate::str::contains("not initialized"));
env.dl()
.env("DOTLOCK_IDENTITY_PASSPHRASE", "")
.args(["cert", "init", "--plain"])
.assert()
.success();
env.dl()
.args(["cert", "show"])
.assert()
.success()
.stdout(predicate::str::contains("passphrase: no"));
}
#[test]
fn share_revoke_cycle_locks_out_revoked_recipient_end_to_end() {
let env = TestEnv::new();
env.init_vault();
env.dl()
.args(["set", "API_KEY", "owner-secret-1"])
.assert()
.success();
env.dl()
.args(["set", "DB_URL", "postgres://owner"])
.assert()
.success();
env.dl()
.args(["cert", "init", "--plain"])
.assert()
.success();
let owner_pub = env.project.join("owner.pub.pem");
env.dl()
.args(["cert", "export-pub"])
.arg(&owner_pub)
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["share", "grant", "--pubkey"])
.arg(&owner_pub)
.args(["--label", "owner"])
.assert()
.success();
let peer_home = env.home.parent().expect("temp root").join("peer-home");
fs::create_dir(&peer_home).expect("create peer home");
env.dl_as(&peer_home)
.args(["cert", "init", "--plain"])
.assert()
.success();
let peer_pub = env.project.join("peer.pub.pem");
env.dl_as(&peer_home)
.args(["cert", "export-pub"])
.arg(&peer_pub)
.assert()
.success();
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["share", "grant", "--pubkey"])
.arg(&peer_pub)
.args(["--label", "peer"])
.assert()
.success();
let out = env
.dl()
.args(["share", "list", "--json"])
.output()
.expect("run share list");
let recipients: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("share list --json must be valid JSON");
let peer_fingerprint = recipients
.as_array()
.expect("array of recipients")
.iter()
.find(|item| item["label"] == "peer")
.expect("peer recipient listed")["fingerprint"]
.as_str()
.expect("fingerprint string")
.to_string();
let vault = fs::read_to_string(env.lock_path("vault.toml")).expect("read vault.toml");
assert!(
vault.contains(&peer_fingerprint),
"peer's wrapped material must be recorded before the revoke:\n{vault}"
);
env.dl_as(&peer_home)
.args(["get", "API_KEY"])
.assert()
.success()
.stdout("owner-secret-1\n");
env.dl()
.env("DOTLOCK_MASTER_PASSWORD", MASTER_PASSWORD)
.args(["share", "revoke", "peer", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("project key rotated"));
let vault = fs::read_to_string(env.lock_path("vault.toml")).expect("read vault.toml");
assert!(
!vault.contains(&peer_fingerprint),
"revoke must remove every trace of the peer's wrapped material:\n{vault}"
);
assert!(
vault.contains("label = \"owner\""),
"the owner recipient must survive the revoke:\n{vault}"
);
env.dl().arg("lock").assert().success();
env.dl()
.args(["get", "API_KEY"])
.assert()
.success()
.stdout("owner-secret-1\n");
env.dl()
.args(["get", "DB_URL"])
.assert()
.success()
.stdout("postgres://owner\n");
env.dl_as(&peer_home).arg("lock").assert().success();
env.dl_as(&peer_home)
.args(["get", "API_KEY"])
.assert()
.failure();
}