use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::Path;
use tempfile::tempdir;
use tsafe_core::{age_crypto, profile, vault::Vault};
#[path = "integration/mod.rs"]
mod integration;
#[path = "integration/test_ns_backlog58.rs"]
mod test_ns_backlog58;
#[path = "integration/test_profile_rename_snapshot.rs"]
mod test_profile_rename_snapshot;
#[path = "integration/test_team_cli_surface.rs"]
mod test_team_cli_surface;
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn frozen_default_core_build_info_capabilities() -> Vec<&'static str> {
vec!["agent", "akv-pull", "biometric", "ssh", "team-core", "tui"]
}
fn expected_build_profile() -> &'static str {
expected_build_profile_from_capabilities(expected_build_info_capabilities())
}
fn expected_build_info_capabilities() -> Vec<&'static str> {
let mut capabilities = Vec::new();
if cfg!(feature = "tui") {
capabilities.push("tui");
}
if cfg!(feature = "akv-pull") {
capabilities.push("akv-pull");
}
if cfg!(feature = "biometric") {
capabilities.push("biometric");
}
if cfg!(feature = "agent") {
capabilities.push("agent");
}
if cfg!(feature = "team-core") {
capabilities.push("team-core");
}
if cfg!(feature = "cloud-pull-aws") {
capabilities.push("cloud-pull-aws");
}
if cfg!(feature = "cloud-pull-gcp") {
capabilities.push("cloud-pull-gcp");
}
if cfg!(feature = "cloud-pull-vault") {
capabilities.push("cloud-pull-vault");
}
if cfg!(feature = "cloud-pull-1password") {
capabilities.push("cloud-pull-1password");
}
if cfg!(feature = "multi-pull") {
capabilities.push("multi-pull");
}
if cfg!(feature = "pm-import-extended") {
capabilities.push("pm-import-extended");
}
if cfg!(feature = "ots-sharing") {
capabilities.push("ots-sharing");
}
if cfg!(feature = "git-helpers") {
capabilities.push("git-helpers");
}
if cfg!(feature = "browser") {
capabilities.push("browser");
}
if cfg!(feature = "nativehost") {
capabilities.push("nativehost");
}
if cfg!(feature = "ssh") {
capabilities.push("ssh");
}
if cfg!(feature = "plugins") {
capabilities.push("plugins");
}
if cfg!(feature = "otel") {
capabilities.push("otel");
}
capabilities.sort_unstable();
capabilities
}
fn parse_build_info_plaintext(stdout: &str) -> (&str, Vec<&str>, BTreeSet<&str>) {
let mut profile = None;
let mut capability_list = Vec::new();
let mut capabilities = BTreeSet::new();
for line in stdout.lines() {
if let Some(value) = line.strip_prefix("build_profile: ") {
profile = Some(value.trim());
}
if let Some(value) = line.strip_prefix("capabilities: ") {
let value = value.trim();
if value != "none" {
for capability in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
capability_list.push(capability);
capabilities.insert(capability);
}
}
}
}
(
profile.expect("build_profile line present"),
capability_list,
capabilities,
)
}
fn parse_build_info_json(stdout: &str) -> (String, Vec<String>, BTreeSet<String>) {
let value: Value = serde_json::from_str(stdout).unwrap();
let profile = value
.get("build_profile")
.and_then(Value::as_str)
.unwrap()
.to_string();
let capability_list: Vec<String> = value
.get("capabilities")
.and_then(Value::as_array)
.unwrap()
.iter()
.map(|value| value.as_str().expect("capability string").to_string())
.collect();
let capabilities = capability_list.iter().cloned().collect();
(profile, capability_list, capabilities)
}
fn expected_build_profile_from_capabilities<'a>(
capabilities: impl IntoIterator<Item = &'a str>,
) -> &'static str {
let capabilities: BTreeSet<&str> = capabilities.into_iter().collect();
let default_core_capabilities: BTreeSet<&str> = frozen_default_core_build_info_capabilities()
.into_iter()
.collect();
if capabilities.is_empty() {
"enterprise-minimal"
} else if capabilities == default_core_capabilities {
"default-core"
} else {
panic!(
"unexpected build-info capability shape {capabilities:?}; expected \
either the frozen default-core set {default_core_capabilities:?} \
or the enterprise-minimal empty set"
);
}
}
fn assert_capability_list_is_sorted_and_unique(capabilities: &[&str]) {
let mut sorted = capabilities.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
capabilities, sorted,
"build-info capability list should be sorted and unique"
);
}
fn assert_capabilities_match_frozen_release_boundary(capabilities: &[&str]) {
let default_core_capabilities = frozen_default_core_build_info_capabilities();
if capabilities.is_empty() {
return;
}
assert_eq!(
capabilities, default_core_capabilities,
"default-core build-info capabilities should stay pinned to the frozen core-only release boundary"
);
}
fn write_age_identity(path: &Path, secret: &str) {
std::fs::write(path, format!("{secret}\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
}
#[test]
fn help_shows_version() {
tsafe()
.arg("--help")
.assert()
.success()
.stdout(contains("tsafe"));
}
#[test]
fn top_level_help_hides_or_shows_feature_gated_commands() {
let assert = tsafe().arg("--help").assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("build-info"));
let gated_commands = [
("tui", "ui"),
("akv-pull", "kv-pull"),
("biometric", "biometric"),
("agent", "agent"),
("team-core", "team"),
("cloud-pull-aws", "aws-pull"),
("cloud-pull-aws", "ssm-pull"),
("cloud-pull-gcp", "gcp-pull"),
("cloud-pull-vault", "vault-pull"),
("cloud-pull-1password", "op-pull"),
("multi-pull", " pull "),
("ots-sharing", "share-once"),
("ots-sharing", "receive-once"),
("git-helpers", "hook-install"),
("git-helpers", "credential-helper"),
("git-helpers", "sync"),
("git-helpers", "git"),
("browser", "browser-profile"),
("browser", "browser-native-host"),
("ssh", "ssh-add"),
("ssh", "ssh-import"),
("plugins", " plugin "),
];
for (feature, command_name) in gated_commands {
let feature_enabled = match feature {
"tui" => cfg!(feature = "tui"),
"akv-pull" => cfg!(feature = "akv-pull"),
"biometric" => cfg!(feature = "biometric"),
"agent" => cfg!(feature = "agent"),
"team-core" => cfg!(feature = "team-core"),
"cloud-pull-aws" => cfg!(feature = "cloud-pull-aws"),
"cloud-pull-gcp" => cfg!(feature = "cloud-pull-gcp"),
"cloud-pull-vault" => cfg!(feature = "cloud-pull-vault"),
"cloud-pull-1password" => cfg!(feature = "cloud-pull-1password"),
"multi-pull" => cfg!(feature = "multi-pull"),
"ots-sharing" => cfg!(feature = "ots-sharing"),
"git-helpers" => cfg!(feature = "git-helpers"),
"browser" => cfg!(feature = "browser"),
"ssh" => cfg!(feature = "ssh"),
"plugins" => cfg!(feature = "plugins"),
other => panic!("unexpected feature under test: {other}"),
};
if feature_enabled {
assert!(
stdout.contains(command_name),
"expected top-level help to include {command_name:?} when feature {feature:?} is enabled"
);
} else {
assert!(
!stdout.contains(command_name),
"expected top-level help to hide {command_name:?} when feature {feature:?} is disabled"
);
}
}
}
#[test]
fn build_info_plaintext_reports_expected_profile_and_known_capabilities() {
let assert = tsafe().args(["build-info"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let (profile, capability_list, capabilities) = parse_build_info_plaintext(&stdout);
let expected_capabilities = expected_build_info_capabilities();
let expected_capability_set: BTreeSet<&str> = expected_capabilities.iter().copied().collect();
assert_eq!(profile, expected_build_profile());
assert_eq!(
profile,
expected_build_profile_from_capabilities(capability_list.iter().copied())
);
assert_eq!(capability_list, expected_capabilities);
assert_eq!(capabilities, expected_capability_set);
assert_capability_list_is_sorted_and_unique(&capability_list);
assert_capabilities_match_frozen_release_boundary(&capability_list);
}
#[test]
fn build_info_json_reports_machine_readable_payload() {
let assert = tsafe().args(["build-info", "--json"]).assert().success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let (profile, capability_list, capabilities) = parse_build_info_json(&stdout);
let expected_capabilities = expected_build_info_capabilities();
let expected_capability_strings: Vec<String> = expected_capabilities
.iter()
.map(|value| value.to_string())
.collect();
let expected_capability_set: BTreeSet<String> =
expected_capability_strings.iter().cloned().collect();
assert_eq!(profile, expected_build_profile());
assert_eq!(
profile,
expected_build_profile_from_capabilities(capability_list.iter().map(String::as_str))
);
assert_eq!(capability_list, expected_capability_strings);
assert_eq!(capabilities, expected_capability_set);
let capability_refs: Vec<&str> = capability_list.iter().map(String::as_str).collect();
assert_capability_list_is_sorted_and_unique(&capability_refs);
assert_capabilities_match_frozen_release_boundary(&capability_refs);
}
#[test]
fn build_info_plaintext_and_json_stay_in_sync() {
let plain_assert = tsafe().args(["build-info"]).assert().success();
let plain_stdout = String::from_utf8(plain_assert.get_output().stdout.clone()).unwrap();
let json_assert = tsafe().args(["build-info", "--json"]).assert().success();
let json_stdout = String::from_utf8(json_assert.get_output().stdout.clone()).unwrap();
let (plain_profile, plain_capability_list, plain_capabilities) =
parse_build_info_plaintext(&plain_stdout);
let (json_profile, json_capability_list, json_capabilities) =
parse_build_info_json(&json_stdout);
let json_capabilities: BTreeSet<&str> = json_capabilities.iter().map(String::as_str).collect();
let json_capability_refs: Vec<&str> = json_capability_list.iter().map(String::as_str).collect();
assert_eq!(plain_profile, json_profile);
assert_eq!(plain_capabilities, json_capabilities);
assert_eq!(plain_capability_list, json_capability_refs);
}
#[cfg(unix)]
#[test]
fn agent_unlock_forwards_first_stdin_line_to_agent() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
let capture_path = dir.path().join("agent-stdin.txt");
let fake_agent = dir.path().join("tsafe-agent-fake");
let fake_agent_script = format!(
"#!/bin/sh\n\
IFS= read -r password_line || password_line=\"\"\n\
printf '%s\\n' \"$password_line\" > '{}'\n\
printf 'TSAFE_AGENT_SOCK=/tmp/tsafe-agent-test::token\\n'\n",
capture_path.display()
);
std::fs::write(&fake_agent, fake_agent_script).unwrap();
std::fs::set_permissions(&fake_agent, std::fs::Permissions::from_mode(0o700)).unwrap();
tsafe()
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_PASSWORD", "agent-pass")
.arg("init")
.assert()
.success();
tsafe()
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGENT_BIN", &fake_agent)
.write_stdin("agent-pass\n")
.args(["agent", "unlock", "--ttl", "1s", "--absolute-ttl", "1s"])
.assert()
.success()
.stderr(contains("Press Enter").not());
assert_eq!(
std::fs::read_to_string(capture_path).unwrap(),
"agent-pass\n"
);
}
#[test]
fn invalid_active_profile_is_rejected_before_snapshot_access() {
tsafe()
.args(["--profile", "../escape", "snapshot", "list"])
.assert()
.failure()
.stderr(contains("only alphanumeric, '-', '_' characters allowed"));
}
#[test]
fn set_and_get_via_vault_api() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", dir.path().to_str(), || {
let path = profile::vault_path("test");
let mut v = Vault::create(&path, b"pw").unwrap();
v.set("DB_URL", "postgres://localhost/db", HashMap::new())
.unwrap();
drop(v);
let v2 = Vault::open(&path, b"pw").unwrap();
assert_eq!(
v2.get("DB_URL").unwrap().as_str(),
"postgres://localhost/db"
);
});
}
#[test]
fn team_vault_cli_crud_and_offboarding_roundtrip() {
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
let alice_identity = dir.path().join("alice.txt");
let bob_identity = dir.path().join("bob.txt");
let (alice_secret, _alice_recipient) = age_crypto::generate_identity();
let (bob_secret, bob_recipient) = age_crypto::generate_identity();
write_age_identity(&alice_identity, &alice_secret);
write_age_identity(&bob_identity, &bob_secret);
tsafe()
.args([
"--profile",
"team",
"team",
"init",
"--identity",
alice_identity.to_str().unwrap(),
])
.env("TSAFE_VAULT_DIR", &vault_dir)
.assert()
.success()
.stdout(contains("Team vault created"));
tsafe()
.args(["--profile", "team", "set", "TEAM_SECRET", "alpha"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &alice_identity)
.assert()
.success();
tsafe()
.args(["--profile", "team", "team", "add-member", &bob_recipient])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &alice_identity)
.env("TSAFE_PASSWORD", "unused")
.args(["--identity", alice_identity.to_str().unwrap()])
.assert()
.success()
.stdout(contains("Added team member"));
tsafe()
.args(["--profile", "team", "get", "TEAM_SECRET"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &bob_identity)
.assert()
.success()
.stdout("alpha");
tsafe()
.args([
"--profile",
"team",
"team",
"remove-member",
&bob_recipient,
"--identity",
alice_identity.to_str().unwrap(),
])
.env("TSAFE_VAULT_DIR", &vault_dir)
.assert()
.success()
.stdout(contains("Removed team member"));
tsafe()
.args(["--profile", "team", "get", "TEAM_SECRET"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &bob_identity)
.assert()
.failure()
.stderr(contains("cannot decrypt team vault"));
tsafe()
.args(["--profile", "team", "get", "TEAM_SECRET"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &alice_identity)
.assert()
.success()
.stdout("alpha");
tsafe()
.args(["--profile", "team", "delete", "TEAM_SECRET"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &alice_identity)
.assert()
.success()
.stdout(contains("Deleted 'TEAM_SECRET'"));
tsafe()
.args(["--profile", "team", "get", "TEAM_SECRET"])
.env("TSAFE_VAULT_DIR", &vault_dir)
.env("TSAFE_AGE_IDENTITY", &alice_identity)
.assert()
.failure()
.stderr(contains("secret 'TEAM_SECRET' not found"));
}
#[test]
fn export_env_format_sorted() {
let dir = tempdir().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", dir.path().to_str(), || {
let path = profile::vault_path("fmt");
let mut v = Vault::create(&path, b"pw").unwrap();
v.set("ZZZ", "z", HashMap::new()).unwrap();
v.set("AAA", "a", HashMap::new()).unwrap();
let all = v.export_all().unwrap();
let out = tsafe_core::env::format_env(&all);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "AAA=a");
assert_eq!(lines[1], "ZZZ=z");
});
}
#[test]
fn set_with_positional_value_warns_on_stderr() {
let dir = tempdir().unwrap();
tsafe()
.args(["--profile", "warnset", "set", "API_KEY", "super-secret"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.success()
.stderr(contains("Secret value passed as a command-line argument"));
}
#[test]
fn gen_words_prints_requested_passphrase_shape_and_stores_it() {
let dir = tempdir().unwrap();
tsafe()
.args(["init"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
let generated = tsafe()
.args(["gen", "RECOVERY_PHRASE", "--words", "4", "--print"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.get_output()
.stdout
.clone();
let generated = String::from_utf8(generated).unwrap();
let generated = generated.trim();
let words: Vec<&str> = generated.split('-').collect();
assert_eq!(
words.len(),
4,
"expected four hyphen-delimited words, got {generated:?}"
);
assert!(
words
.iter()
.all(|word| !word.is_empty() && word.chars().all(|c| c.is_ascii_lowercase())),
"expected lowercase ASCII words, got {generated:?}"
);
let stored = tsafe()
.args(["get", "RECOVERY_PHRASE"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(String::from_utf8(stored).unwrap().trim(), generated);
}
#[test]
fn totp_add_from_otpauth_uri_and_get_returns_live_code() {
let dir = tempdir().unwrap();
let seed_uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example";
tsafe()
.args(["init"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
tsafe()
.args(["totp", "add", "GITHUB_2FA", seed_uri])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(contains("TOTP seed stored"));
tsafe()
.args(["list", "--tag", "type=totp"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(contains("GITHUB_2FA"));
let code = tsafe()
.args(["totp", "get", "GITHUB_2FA"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.get_output()
.stdout
.clone();
let code = String::from_utf8(code).unwrap();
let code = code.trim();
let (digits, suffix) = code
.split_once(" (")
.expect("TOTP output should include code and seconds remaining");
assert_eq!(
digits.len(),
6,
"expected a 6-digit TOTP code, got {code:?}"
);
assert!(
digits.chars().all(|c| c.is_ascii_digit()),
"expected numeric TOTP code, got {code:?}"
);
assert!(
suffix.ends_with("s remaining)"),
"expected seconds remaining suffix, got {code:?}"
);
}
#[test]
fn qr_command_renders_and_clears_terminal_for_stored_totp_seed() {
let dir = tempdir().unwrap();
let seed_uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example";
tsafe()
.args(["init"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
tsafe()
.args(["totp", "add", "GITHUB_2FA", seed_uri])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(contains("TOTP seed stored"));
tsafe()
.args(["qr", "GITHUB_2FA"])
.write_stdin("\n")
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stderr(contains("Press Enter to clear..."))
.stdout(contains("\u{1b}[2J\u{1b}[H"));
}
#[cfg_attr(not(feature = "git-helpers"), ignore = "requires git-helpers feature")]
#[test]
fn credential_helper_store_and_get_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("gitcreds.vault");
Vault::create(&path, b"pw").unwrap();
tsafe()
.args(["--profile", "gitcreds", "credential-helper", "store"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_secret\n\n")
.assert()
.success();
let vault = Vault::open(&path, b"pw").unwrap();
assert_eq!(
vault.get("GITHUB_COM_USERNAME").unwrap().as_str(),
"octocat"
);
assert_eq!(
vault.get("GITHUB_COM_PASSWORD").unwrap().as_str(),
"ghp_secret"
);
assert_eq!(
vault.file().secrets["GITHUB_COM_PASSWORD"].tags.get("type"),
Some(&"git-credential".to_string())
);
assert_eq!(
vault.file().secrets["GITHUB_COM_PASSWORD"]
.tags
.get("credential_field"),
Some(&"password".to_string())
);
drop(vault);
tsafe()
.args(["--profile", "gitcreds", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=github.com\n\n")
.assert()
.success()
.stdout(contains("username=octocat"))
.stdout(contains("password=ghp_secret"));
}
#[cfg_attr(not(feature = "git-helpers"), ignore = "requires git-helpers feature")]
#[test]
fn credential_helper_erase_removes_stored_credentials() {
let dir = tempdir().unwrap();
let path = dir.path().join("giterase.vault");
Vault::create(&path, b"pw").unwrap();
tsafe()
.args(["--profile", "giterase", "credential-helper", "store"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_secret\n\n")
.assert()
.success();
tsafe()
.args(["--profile", "giterase", "credential-helper", "erase"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=github.com\n\n")
.assert()
.success();
let vault = Vault::open(&path, b"pw").unwrap();
assert!(!vault.file().secrets.contains_key("GITHUB_COM_USERNAME"));
assert!(!vault.file().secrets.contains_key("GITHUB_COM_PASSWORD"));
}
#[cfg_attr(not(feature = "git-helpers"), ignore = "requires git-helpers feature")]
#[test]
fn credential_helper_missing_vault_returns_no_credentials() {
let dir = tempdir().unwrap();
tsafe()
.args(["--profile", "missing", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.write_stdin("protocol=https\nhost=github.com\n\n")
.assert()
.success()
.stdout(contains("username=").not())
.stdout(contains("password=").not());
}
#[cfg_attr(not(feature = "git-helpers"), ignore = "requires git-helpers feature")]
#[test]
fn credential_helper_path_scopes_credentials() {
let dir = tempdir().unwrap();
let path = dir.path().join("gitpaths.vault");
Vault::create(&path, b"pw").unwrap();
tsafe()
.args(["--profile", "gitpaths", "credential-helper", "store"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin(
"protocol=https\nhost=example.com\npath=repo1\nusername=user1\npassword=pass1\n\n",
)
.assert()
.success();
tsafe()
.args(["--profile", "gitpaths", "credential-helper", "store"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin(
"protocol=https\nhost=example.com\npath=repo2\nusername=user2\npassword=pass2\n\n",
)
.assert()
.success();
tsafe()
.args(["--profile", "gitpaths", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=example.com\npath=repo1\n\n")
.assert()
.success()
.stdout(contains("username=user1"))
.stdout(contains("password=pass1"))
.stdout(contains("user2").not())
.stdout(contains("pass2").not());
tsafe()
.args(["--profile", "gitpaths", "credential-helper", "erase"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=example.com\npath=repo1\n\n")
.assert()
.success();
tsafe()
.args(["--profile", "gitpaths", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=example.com\npath=repo2\n\n")
.assert()
.success()
.stdout(contains("username=user2"))
.stdout(contains("password=pass2"));
}
#[cfg(feature = "git-helpers")]
#[test]
fn credential_helper_full_protocol_flow_store_then_get() {
let dir = tempdir().unwrap();
let path = dir.path().join("credflow.vault");
Vault::create(&path, b"pw").unwrap();
tsafe()
.args(["--profile", "credflow", "credential-helper", "store"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=gitlab.com\nusername=devuser\npassword=glpat-secret\n\n")
.assert()
.success();
tsafe()
.args(["--profile", "credflow", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=gitlab.com\n\n")
.assert()
.success()
.stdout(contains("username=devuser"))
.stdout(contains("password=glpat-secret"));
tsafe()
.args(["--profile", "credflow", "credential-helper", "erase"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=gitlab.com\n\n")
.assert()
.success();
tsafe()
.args(["--profile", "credflow", "credential-helper", "get"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.write_stdin("protocol=https\nhost=gitlab.com\n\n")
.assert()
.success()
.stdout(contains("username=").not())
.stdout(contains("password=").not());
}
#[cfg(feature = "git-helpers")]
#[test]
fn credential_helper_install_global_runs_git_config() {
let git_available = std::process::Command::new("git")
.args(["--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !git_available {
eprintln!("skipping credential_helper_install_global_runs_git_config: git not found");
return;
}
let dir = tempdir().unwrap();
tsafe()
.args(["credential-helper", "install", "--global"])
.env("HOME", dir.path())
.env("USERPROFILE", dir.path()) .env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("TSAFE_PROFILE")
.assert()
.success()
.stdout(contains("Configured git global credential.helper"));
}
#[test]
fn snapshot_list_shows_entry_after_save() {
let dir = tempdir().unwrap();
let path = dir.path().join("snaptest.vault");
let mut v = Vault::create(&path, b"pw").unwrap();
v.set("KEY", "val", HashMap::new()).unwrap(); drop(v);
tsafe()
.args(["--profile", "snaptest", "snapshot", "list"])
.env("TSAFE_VAULT_DIR", dir.path())
.assert()
.success()
.stdout(contains("snaptest"));
}
#[test]
fn snapshot_list_empty_is_not_an_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("emptysnap.vault");
Vault::create(&path, b"pw").unwrap();
tsafe()
.args(["--profile", "emptysnap", "snapshot", "list"])
.env("TSAFE_VAULT_DIR", dir.path())
.assert()
.success()
.stdout(contains("No snapshots"));
}
#[test]
fn list_tag_filter_returns_only_matching_keys() {
let dir = tempdir().unwrap();
let path = dir.path().join("taglist.vault");
let mut v = Vault::create(&path, b"pw").unwrap();
let mut prod_tags = HashMap::new();
prod_tags.insert("env".to_string(), "prod".to_string());
v.set("PROD_KEY", "pval", prod_tags).unwrap();
v.set("DEV_KEY", "dval", HashMap::new()).unwrap();
drop(v);
tsafe()
.args(["--profile", "taglist", "list", "--tag", "env=prod"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.success()
.stdout(contains("PROD_KEY"))
.stdout(predicates::str::contains("DEV_KEY").not());
}
#[test]
fn export_tag_filter_excludes_untagged_keys() {
let dir = tempdir().unwrap();
let path = dir.path().join("tagexport.vault");
let mut v = Vault::create(&path, b"pw").unwrap();
let mut prod_tags = HashMap::new();
prod_tags.insert("env".to_string(), "prod".to_string());
v.set("PROD_VAR", "secret", prod_tags).unwrap();
v.set("CI_VAR", "other", HashMap::new()).unwrap();
drop(v);
tsafe()
.args(["--profile", "tagexport", "export", "--tag", "env=prod"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.success()
.stdout(contains("PROD_VAR"))
.stdout(predicates::str::contains("CI_VAR").not());
}
#[test]
fn kv_pull_missing_config_prints_error() {
tsafe()
.args(["kv-pull"])
.env_remove("TSAFE_AKV_URL")
.assert()
.failure()
.stderr(contains("TSAFE_AKV_URL"));
}
#[cfg_attr(not(feature = "ots-sharing"), ignore = "requires ots-sharing feature")]
#[test]
fn snap_rejects_non_https_service_url_before_network_use() {
let dir = tempdir().unwrap();
let path = dir.path().join("snapbad.vault");
let mut v = Vault::create(&path, b"pw").unwrap();
v.set("API_KEY", "secret", HashMap::new()).unwrap();
drop(v);
tsafe()
.args(["--profile", "snapbad", "share-once", "API_KEY"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.env("TSAFE_OTS_BASE_URL", "http://snap.example.test")
.assert()
.failure()
.stderr(contains("TSAFE_OTS_BASE_URL must start with https://"));
}
#[cfg_attr(not(feature = "ots-sharing"), ignore = "requires ots-sharing feature")]
#[test]
fn snap_receive_rejects_non_https_urls() {
tsafe()
.args(["snap-receive", "http://snap.example.test/s/token123"])
.assert()
.failure()
.stderr(contains("URL must use https://"));
}