use std::path::{Path, PathBuf};
use assert_cmd::Command;
use serde_json::Value;
use tempfile::{tempdir, TempDir};
use tsafe_core::events::key_ref;
const PROFILE: &str = "exaudit";
const PASSWORD: &str = "test-pw";
#[cfg(not(target_os = "windows"))]
const CONTRACT_TARGET: &str = "printenv";
#[cfg(target_os = "windows")]
const CONTRACT_TARGET: &str = "cmd";
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn vault_dir(dir: &TempDir) -> PathBuf {
let vaults = dir.path().join("vaults");
std::fs::create_dir_all(&vaults).unwrap();
vaults
}
fn write_manifest(dir: &Path, yaml: &str) {
std::fs::write(dir.join(".tsafe.yml"), yaml).unwrap();
}
fn seed_exec_audit_fixture() -> (TempDir, PathBuf) {
let dir = tempdir().unwrap();
let vaults = vault_dir(&dir);
write_manifest(
dir.path(),
format!(
r#"
contracts:
deploy:
profile: {PROFILE}
allowed_secrets: [ALLOWED_KEY]
required_secrets: [ALLOWED_KEY]
allowed_targets: [{CONTRACT_TARGET}]
trust_level: standard
"#
)
.as_str(),
);
tsafe()
.args(["--profile", PROFILE, "init"])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", &vaults)
.env("TSAFE_PASSWORD", PASSWORD)
.assert()
.success();
tsafe()
.args(["--profile", PROFILE, "set", "ALLOWED_KEY", "allowed-value"])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", &vaults)
.env("TSAFE_PASSWORD", PASSWORD)
.assert()
.success();
#[cfg(not(target_os = "windows"))]
let exec_args: &[&str] = &[
"--profile",
PROFILE,
"exec",
"--contract",
"deploy",
"--",
"printenv",
];
#[cfg(target_os = "windows")]
let exec_args: &[&str] = &[
"--profile",
PROFILE,
"exec",
"--contract",
"deploy",
"--",
"cmd",
"/c",
"set",
];
let exec = tsafe()
.args(exec_args)
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", &vaults)
.env("TSAFE_PASSWORD", PASSWORD)
.env("GITHUB_TOKEN", "parent-token-should-not-leak")
.assert()
.success();
let exec_stdout = String::from_utf8(exec.get_output().stdout.clone()).unwrap();
assert!(exec_stdout.contains("ALLOWED_KEY=allowed-value"));
(dir, vaults)
}
fn audit_explain_human(dir: &TempDir, vaults: &Path) -> String {
let explain = tsafe()
.args(["--profile", PROFILE, "audit", "--explain", "--limit", "20"])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", vaults)
.assert()
.success();
String::from_utf8(explain.get_output().stdout.clone()).unwrap()
}
fn audit_explain_json(dir: &TempDir, vaults: &Path) -> Value {
let explain = tsafe()
.args([
"--profile",
PROFILE,
"audit",
"--explain",
"--json",
"--limit",
"20",
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", vaults)
.assert()
.success();
serde_json::from_slice(&explain.get_output().stdout).unwrap()
}
fn explained_exec_authority(timeline: &Value) -> &Value {
timeline["sessions"]
.as_array()
.unwrap()
.iter()
.flat_map(|session| session["operations"].as_array().unwrap().iter())
.find_map(|op| {
if op["operation"].as_str() == Some("exec") {
op.get("authority")
} else {
None
}
})
.unwrap()
}
#[test]
fn audit_explain_human_answers_granted_allowed_and_stripped_questions() {
let (dir, vaults) = seed_exec_audit_fixture();
let stdout = audit_explain_human(&dir, &vaults);
let allowed_key_ref = key_ref(PROFILE, "ALLOWED_KEY");
assert!(stdout.contains("Session"));
assert!(stdout.contains("granted:"));
assert!(stdout.contains("contract=deploy"));
assert!(stdout.contains(&format!("profile={PROFILE}")));
assert!(stdout.contains("trust=standard"));
assert!(stdout.contains("inherit=full"));
assert!(stdout.contains("injected:"));
assert!(stdout.contains(&allowed_key_ref));
assert!(stdout.contains("required:"));
assert!(stdout.contains("allowed:"));
assert!(stdout.contains("target:"));
assert!(stdout.contains(CONTRACT_TARGET));
assert!(stdout.contains("allowed_exact"));
assert!(stdout.contains("denied/stripped:"));
assert!(stdout.contains("stripped: GITHUB_TOKEN"));
assert!(!stdout.contains("deny_dangerous_env"));
assert!(!stdout.contains("redact_output"));
assert!(!stdout.contains("target did not match contract"));
assert!(!stdout.contains("required but missing:"));
assert!(!stdout.contains("injected outside contract:"));
assert!(!stdout.contains("audit gaps:"));
}
#[test]
fn audit_explain_json_answers_granted_allowed_and_stripped_questions() {
let (dir, vaults) = seed_exec_audit_fixture();
let timeline = audit_explain_json(&dir, &vaults);
let authority = explained_exec_authority(&timeline);
let allowed_key_ref = key_ref(PROFILE, "ALLOWED_KEY");
assert!(timeline["sessions"].is_array());
assert_eq!(authority["contract_name"].as_str(), Some("deploy"));
assert_eq!(authority["authority_profile"].as_str(), Some(PROFILE));
assert_eq!(authority["trust_level"].as_str(), Some("standard"));
assert_eq!(authority["inherit"].as_str(), Some("full"));
assert_eq!(authority["deny_dangerous_env"].as_bool(), Some(false));
assert_eq!(authority["redact_output"].as_bool(), Some(false));
assert_eq!(authority["target"].as_str(), Some(CONTRACT_TARGET));
assert_eq!(authority["target_decision"].as_str(), Some("allowed_exact"));
assert_eq!(authority["matched_target"].as_str(), Some(CONTRACT_TARGET));
assert_eq!(
authority["injected_secret_refs"].as_array().unwrap(),
&vec![Value::String(allowed_key_ref.clone())]
);
assert_eq!(
authority["required_secret_refs"].as_array().unwrap(),
&vec![Value::String(allowed_key_ref.clone())]
);
assert_eq!(
authority["allowed_secret_refs"].as_array().unwrap(),
&vec![Value::String(allowed_key_ref)]
);
let dropped_env_names = authority["contract_diff"]["dropped_env_names"]
.as_array()
.unwrap();
assert!(dropped_env_names.contains(&Value::String("GITHUB_TOKEN".to_string())));
assert!(authority["contract_diff"]["unexpected_injected_secret_refs"].is_null());
assert!(authority["contract_diff"]["missing_required_secret_refs"].is_null());
assert_eq!(
authority["contract_diff"]["target_mismatch"].as_bool(),
Some(false)
);
assert!(authority["gaps"].is_null());
}
#[test]
fn audit_json_without_explain_fails_at_cli() {
tsafe().args(["audit", "--json"]).assert().failure();
}
#[test]
fn audit_verify_on_empty_log_exits_zero() {
let dir = tempdir().unwrap();
let vaults = vault_dir(&dir);
let assert = tsafe()
.args(["--profile", PROFILE, "audit-verify"])
.env("TSAFE_VAULT_DIR", &vaults)
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("0 entries"),
"expected '0 entries' in output, got: {stdout:?}"
);
assert!(
stdout.contains("0 chained"),
"expected '0 chained' in output, got: {stdout:?}"
);
}
#[test]
fn audit_verify_json_output_shape() {
let dir = tempdir().unwrap();
let vaults = vault_dir(&dir);
let assert = tsafe()
.args(["--profile", PROFILE, "audit-verify", "--json"])
.env("TSAFE_VAULT_DIR", &vaults)
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let value: Value =
serde_json::from_str(stdout.trim()).expect("audit-verify --json should emit valid JSON");
assert!(
value.get("total").is_some(),
"JSON output must contain 'total'"
);
assert!(
value.get("chained").is_some(),
"JSON output must contain 'chained'"
);
assert!(
value.get("unchained").is_some(),
"JSON output must contain 'unchained'"
);
assert!(
value.get("chain_coverage_pct").is_some(),
"JSON output must contain 'chain_coverage_pct'"
);
assert_eq!(
value["total"].as_u64(),
Some(0),
"empty log should report total=0"
);
}
#[test]
fn audit_verify_unchained_entries_reported() {
use std::io::Write;
let dir = tempdir().unwrap();
let vaults = vault_dir(&dir);
let audit_dir = dir.path().join("state").join("audit");
std::fs::create_dir_all(&audit_dir).unwrap();
let log_path = audit_dir.join(format!("{PROFILE}.audit.jsonl"));
let mut f = std::fs::File::create(&log_path).unwrap();
writeln!(
f,
r#"{{"id":"aaaaaaaa-0000-0000-0000-000000000001","timestamp":"2024-01-01T00:00:00Z","profile":"{PROFILE}","operation":"vault.unlock","key":null,"status":"success"}}"#
)
.unwrap();
writeln!(
f,
r#"{{"id":"aaaaaaaa-0000-0000-0000-000000000002","timestamp":"2024-01-01T00:01:00Z","profile":"{PROFILE}","operation":"secret.get","key":"MY_KEY","status":"success"}}"#
)
.unwrap();
let assert = tsafe()
.args(["--profile", PROFILE, "audit-verify", "--json"])
.env("TSAFE_VAULT_DIR", &vaults)
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let value: Value =
serde_json::from_str(stdout.trim()).expect("audit-verify --json should emit valid JSON");
assert_eq!(value["total"].as_u64(), Some(2));
assert_eq!(value["chained"].as_u64(), Some(0));
assert_eq!(value["unchained"].as_u64(), Some(2));
assert_eq!(
value["chain_coverage_pct"].as_f64(),
Some(0.0),
"no prev_entry_hmac fields → 0% coverage"
);
}