tsafe-cli 1.2.0

Local-first secrets runtime for developers — inject credentials via exec, never shell history or .env files
Documentation
use assert_cmd::Command;
use chrono::{Duration, Utc};
use serde_json::{json, Value};
use tempfile::tempdir;
use tsafe_core::{age_crypto, team};

fn tsafe() -> Command {
    Command::cargo_bin("tsafe").unwrap()
}

fn help_stdout(args: &[&str]) -> String {
    let output = tsafe().args(args).output().unwrap();
    assert!(
        output.status.success(),
        "stderr:\n{}\nstdout:\n{}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout)
    );
    String::from_utf8_lossy(&output.stdout).to_string()
}

#[test]
fn mobile_help_exposes_operator_and_release_boundaries() {
    let help = help_stdout(&["mobile", "--help"]);

    assert!(help.contains("public enrollment envelopes only"));
    assert!(help.contains("release readiness"));
    assert!(help.contains("store-console acceptance"));
    assert!(help.contains("device custody"));
    assert!(help.contains("platform runtime evidence"));
}

#[test]
fn mobile_enroll_help_exposes_source_local_handoff_boundary() {
    let enroll_help = help_stdout(&["mobile", "enroll", "--help"]);
    let start_help = help_stdout(&["mobile", "enroll", "start", "--help"]);
    let accept_help = help_stdout(&["mobile", "enroll", "accept", "--help"]);

    assert!(enroll_help.contains("source-local"));
    assert!(enroll_help.contains("operator-held evidence"));
    assert!(start_help.contains("public enrollment payload"));
    assert!(start_help.contains("no secret values"));
    assert!(accept_help.contains("operator age identity"));
    assert!(accept_help.contains("handoff_required"));
}

fn start_enrollment(dir: &tempfile::TempDir, profile: &str) -> Value {
    start_enrollment_with_vault_path(dir, profile, None)
}

fn start_enrollment_with_vault_path(
    dir: &tempfile::TempDir,
    profile: &str,
    vault_path: Option<&std::path::Path>,
) -> Value {
    let mut args = vec![
        "--profile".to_string(),
        profile.to_string(),
        "mobile".to_string(),
        "enroll".to_string(),
        "start".to_string(),
        "--repo".to_string(),
        "example-org/example-vault".to_string(),
        "--json".to_string(),
    ];
    if let Some(vault_path) = vault_path {
        args.push("--vault-path".to_string());
        args.push(vault_path.display().to_string());
    }

    let output = tsafe()
        .args(args)
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .env("TOP_SECRET_CANARY", "do-not-emit-this")
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr:\n{}\nstdout:\n{}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(!stdout.contains("do-not-emit-this"));
    assert!(!stdout.to_ascii_lowercase().contains("private_key"));
    assert!(!stdout.to_ascii_lowercase().contains("password"));
    serde_json::from_slice(&output.stdout).unwrap()
}

fn response_for(start: &Value, profile: &str, nonce: &str) -> (Value, String, String) {
    let (identity, recipient) = age_crypto::generate_identity();
    let now = Utc::now();
    let response = json!({
        "schema": "tsafe/mobile/enrollment-response/v1",
        "version": "1.0",
        "status": "accepted",
        "session_id": start["session_id"],
        "created_at": now.to_rfc3339(),
        "expires_at": (now + Duration::minutes(10)).to_rfc3339(),
        "nonce": nonce,
        "repo": start["repo"],
        "profile": profile,
        "mobile_recipient": recipient,
        "device_label": "Test phone"
    });
    (response, recipient, identity)
}

#[test]
fn mobile_enroll_start_json_is_public_and_safe() {
    let dir = tempdir().unwrap();
    let start = start_enrollment(&dir, "mobile-start-safe");

    assert_eq!(start["schema"], "tsafe/mobile/enrollment-start/v1");
    assert_eq!(start["profile"], "mobile-start-safe");
    assert_eq!(start["repo"]["owner"], "example-org");
    assert_eq!(start["repo"]["name"], "example-vault");
    assert!(start["nonce"].as_str().unwrap().len() >= 32);
    assert!(start.get("desktop_public_key").is_some());
    assert!(start.get("private_key").is_none());
    assert!(start.get("password").is_none());
    assert!(start.get("secret").is_none());
}

#[test]
fn mobile_enroll_accept_records_recipient_and_reports_rewrap_handoff() {
    let dir = tempdir().unwrap();
    let profile = "mobile-accept";
    let start = start_enrollment(&dir, profile);
    let nonce = start["nonce"].as_str().unwrap();
    let (response, recipient, _) = response_for(&start, profile, nonce);
    let start_path = dir.path().join("start.json");
    let response_path = dir.path().join("response.json");
    let team_keys_path = dir.path().join(".tsafe/team-keys.json");
    std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
    std::fs::write(
        &response_path,
        serde_json::to_vec_pretty(&response).unwrap(),
    )
    .unwrap();

    let output = tsafe()
        .args([
            "--profile",
            profile,
            "mobile",
            "enroll",
            "accept",
            "--start",
            start_path.to_str().unwrap(),
            "--response",
            response_path.to_str().unwrap(),
            "--team-keys",
            team_keys_path.to_str().unwrap(),
            "--json",
        ])
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr:\n{}\nstdout:\n{}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(!stdout.to_ascii_lowercase().contains("private_key"));
    assert!(!stdout.to_ascii_lowercase().contains("password"));

    let receipt: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(receipt["status"], "accepted");
    assert_eq!(receipt["profile"], profile);
    assert_eq!(receipt["nonce"], nonce);
    assert_eq!(receipt["mobile_recipient"], recipient);
    assert_eq!(receipt["team_rewrap"]["status"], "handoff_required");
    assert_eq!(
        receipt["team_rewrap"]["reason"],
        "source-local team vault rewrap requires an operator age identity"
    );

    let team_keys: Value =
        serde_json::from_slice(&std::fs::read(&team_keys_path).unwrap()).unwrap();
    assert_eq!(team_keys["members"][0]["public_key"], recipient);
    assert_eq!(team_keys["members"][0]["name"], "Test phone");
}

#[test]
fn mobile_enroll_accept_rewraps_source_local_team_vault_for_mobile_recipient() {
    let dir = tempdir().unwrap();
    let profile = "mobile-rewrap";
    let vault_path = dir.path().join(".tsafe/vaults/mobile-rewrap.vault");
    let operator_identity_path = dir.path().join("operator-age.txt");
    let mobile_identity_path = dir.path().join("mobile-age.txt");
    let team_keys_path = dir.path().join(".tsafe/team-keys.json");

    let (operator_identity, operator_recipient) = age_crypto::generate_identity();
    std::fs::write(&operator_identity_path, &operator_identity).unwrap();
    let (file, _) = team::create_team_vault(&[operator_recipient]).unwrap();
    std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
    std::fs::write(&vault_path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();

    let start = start_enrollment_with_vault_path(&dir, profile, Some(&vault_path));
    let nonce = start["nonce"].as_str().unwrap();
    let (response, recipient, mobile_identity) = response_for(&start, profile, nonce);
    std::fs::write(&mobile_identity_path, &mobile_identity).unwrap();
    let start_path = dir.path().join("start.json");
    let response_path = dir.path().join("response.json");
    std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
    std::fs::write(
        &response_path,
        serde_json::to_vec_pretty(&response).unwrap(),
    )
    .unwrap();

    let output = tsafe()
        .args([
            "--profile",
            profile,
            "mobile",
            "enroll",
            "accept",
            "--start",
            start_path.to_str().unwrap(),
            "--response",
            response_path.to_str().unwrap(),
            "--team-keys",
            team_keys_path.to_str().unwrap(),
            "--identity",
            operator_identity_path.to_str().unwrap(),
            "--json",
        ])
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr:\n{}\nstdout:\n{}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout)
    );

    let receipt: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(receipt["team_rewrap"]["status"], "rewrapped");
    assert_eq!(receipt["team_rewrap"]["already_rewrapped"], false);
    assert_eq!(receipt["team_rewrap"]["recipient_count"], 2);

    let rewrapped_file: tsafe_core::vault::VaultFile =
        serde_json::from_slice(&std::fs::read(&vault_path).unwrap()).unwrap();
    assert!(rewrapped_file.age_recipients.contains(&recipient));
    let mobile_identities = age_crypto::load_identities(&mobile_identity_path).unwrap();
    team::unwrap_dek(&rewrapped_file, &mobile_identities).unwrap();

    let team_keys: Value =
        serde_json::from_slice(&std::fs::read(&team_keys_path).unwrap()).unwrap();
    assert_eq!(team_keys["members"][0]["public_key"], recipient);
}

#[test]
fn mobile_enroll_accept_rejects_nonce_mismatch() {
    let dir = tempdir().unwrap();
    let profile = "mobile-reject-nonce";
    let start = start_enrollment(&dir, profile);
    let (response, _, _) = response_for(&start, profile, "wrong-nonce");
    let start_path = dir.path().join("start.json");
    let response_path = dir.path().join("response.json");
    let team_keys_path = dir.path().join(".tsafe/team-keys.json");
    std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
    std::fs::write(
        &response_path,
        serde_json::to_vec_pretty(&response).unwrap(),
    )
    .unwrap();

    tsafe()
        .args([
            "--profile",
            profile,
            "mobile",
            "enroll",
            "accept",
            "--start",
            start_path.to_str().unwrap(),
            "--response",
            response_path.to_str().unwrap(),
            "--team-keys",
            team_keys_path.to_str().unwrap(),
        ])
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .assert()
        .failure()
        .stderr(predicates::str::contains("nonce mismatch"));

    assert!(!team_keys_path.exists());
}

#[test]
fn mobile_enroll_accept_rejects_profile_mismatch() {
    let dir = tempdir().unwrap();
    let profile = "mobile-reject-profile";
    let start = start_enrollment(&dir, profile);
    let nonce = start["nonce"].as_str().unwrap();
    let (response, _, _) = response_for(&start, "other-profile", nonce);
    let start_path = dir.path().join("start.json");
    let response_path = dir.path().join("response.json");
    let team_keys_path = dir.path().join(".tsafe/team-keys.json");
    std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
    std::fs::write(
        &response_path,
        serde_json::to_vec_pretty(&response).unwrap(),
    )
    .unwrap();

    tsafe()
        .args([
            "--profile",
            profile,
            "mobile",
            "enroll",
            "accept",
            "--start",
            start_path.to_str().unwrap(),
            "--response",
            response_path.to_str().unwrap(),
            "--team-keys",
            team_keys_path.to_str().unwrap(),
        ])
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .assert()
        .failure()
        .stderr(predicates::str::contains("profile mismatch"));

    assert!(!team_keys_path.exists());
}

#[test]
fn mobile_enroll_accept_rejects_secret_like_response_fields() {
    let dir = tempdir().unwrap();
    let profile = "mobile-reject-secret";
    let start = start_enrollment(&dir, profile);
    let nonce = start["nonce"].as_str().unwrap();
    let (mut response, _, _) = response_for(&start, profile, nonce);
    response["private_key"] = json!("forbidden-public-test-marker");
    let start_path = dir.path().join("start.json");
    let response_path = dir.path().join("response.json");
    let team_keys_path = dir.path().join(".tsafe/team-keys.json");
    std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
    std::fs::write(
        &response_path,
        serde_json::to_vec_pretty(&response).unwrap(),
    )
    .unwrap();

    tsafe()
        .args([
            "--profile",
            profile,
            "mobile",
            "enroll",
            "accept",
            "--start",
            start_path.to_str().unwrap(),
            "--response",
            response_path.to_str().unwrap(),
            "--team-keys",
            team_keys_path.to_str().unwrap(),
        ])
        .current_dir(dir.path())
        .env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
        .assert()
        .failure()
        .stderr(predicates::str::contains("secret-like material"));

    assert!(!team_keys_path.exists());
}