tsafe-cli 1.0.25

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
Documentation
//! Integration tests for `tsafe audit` and `tsafe audit --explain`.

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();
}

// ── audit-verify ─────────────────────────────────────────────────────────────

/// `tsafe audit-verify` on an empty (non-existent) log exits 0 and reports 0/0.
#[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:?}"
    );
}

/// `tsafe audit-verify --json` produces the required JSON fields.
#[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"
    );
}

/// Entries without `prev_entry_hmac` (pre-C8 or session boundaries) are
/// counted as unchained, and chain coverage is 0%.
#[test]
fn audit_verify_unchained_entries_reported() {
    use std::io::Write;

    let dir = tempdir().unwrap();
    let vaults = vault_dir(&dir);

    // With TSAFE_VAULT_DIR=<vaults>, audit logs go to <dir>/state/audit/<profile>.audit.jsonl
    let audit_dir = dir.path().join("state").join("audit");
    std::fs::create_dir_all(&audit_dir).unwrap();

    // Write a JSONL audit log with two entries that have no prev_entry_hmac field.
    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"
    );
}