tsafe-cli 1.0.26

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! Integration tests for `tsafe agent status --json` (ADR-029 seam).
//!
//! The agent daemon (`tsafe-agent`) is NOT started in these tests — running it
//! requires an interactive vault-password prompt that is not available in a
//! headless CI environment.  The tests cover the "no agent running" path, which
//! is the primary stable state all consumers must handle gracefully.
//!
//! Schema contract being tested (version "1"):
//! - `version` == "1"
//! - `agent_running` is a boolean (false when no agent is running)
//! - `vault_locked` is a boolean (true when no agent is running)
//! - `active_profile` is a string
//! - `biometric_enabled` is a boolean
//! - `session_expires_at` is null or a string
//! - `idle_ttl_remaining_secs` is null or an integer
//! - `absolute_ttl_remaining_secs` is null or an integer
//! - `agent_pid` is null or an integer

use assert_cmd::Command;
use serde_json::Value;
use tempfile::tempdir;

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

/// Run `tsafe agent status --json` for a given profile in an isolated vault dir.
fn agent_status_json(profile: &str) -> (i32, String, String) {
    let dir = tempdir().unwrap();
    let output = tsafe()
        .args(["--profile", profile, "agent", "status", "--json"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env_remove("TSAFE_AGENT_SOCK")
        .env_remove("TSAFE_PROFILE")
        .output()
        .unwrap();
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

// ── Exit code ─────────────────────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_exits_zero_when_no_agent_running() {
    let (code, stdout, stderr) = agent_status_json("default");
    assert_eq!(
        code, 0,
        "agent status --json must exit 0 even when no agent is running\nstdout: {stdout}\nstderr: {stderr}"
    );
}

// ── Valid JSON ────────────────────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_output_is_valid_json() {
    let (_, stdout, _) = agent_status_json("default");
    let parsed: Result<Value, _> = serde_json::from_str(&stdout);
    assert!(
        parsed.is_ok(),
        "agent status --json must produce valid JSON\nraw output:\n{stdout}"
    );
}

// ── Schema version ────────────────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_version_field_is_string_one() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert_eq!(
        json["version"],
        Value::String("1".to_string()),
        "`version` must be the string \"1\" in schema version 1\nfull output:\n{stdout}"
    );
}

// ── Required boolean fields ───────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_agent_running_is_bool() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["agent_running"].is_boolean(),
        "`agent_running` must be a boolean\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_vault_locked_is_bool() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["vault_locked"].is_boolean(),
        "`vault_locked` must be a boolean\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_biometric_enabled_is_bool() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["biometric_enabled"].is_boolean(),
        "`biometric_enabled` must be a boolean\nfull output:\n{stdout}"
    );
}

// ── Required string fields ────────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_active_profile_is_string() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["active_profile"].is_string(),
        "`active_profile` must be a string\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_active_profile_reflects_requested_profile() {
    let (_, stdout, _) = agent_status_json("myprod");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert_eq!(
        json["active_profile"].as_str(),
        Some("myprod"),
        "`active_profile` must reflect the --profile argument\nfull output:\n{stdout}"
    );
}

// ── No-agent-running invariants ───────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_no_agent_reports_agent_running_false() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert_eq!(
        json["agent_running"],
        Value::Bool(false),
        "`agent_running` must be false when no agent socket is set\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_no_agent_reports_vault_locked_true() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert_eq!(
        json["vault_locked"],
        Value::Bool(true),
        "`vault_locked` must be true when no agent is running\nfull output:\n{stdout}"
    );
}

// ── Nullable fields (null in version 1) ──────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_session_expires_at_is_null_or_string() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    let field = &json["session_expires_at"];
    assert!(
        field.is_null() || field.is_string(),
        "`session_expires_at` must be null or an ISO-8601 string\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_idle_ttl_remaining_secs_is_null_or_u64() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    let field = &json["idle_ttl_remaining_secs"];
    assert!(
        field.is_null() || field.is_u64(),
        "`idle_ttl_remaining_secs` must be null or a non-negative integer\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_absolute_ttl_remaining_secs_is_null_or_u64() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    let field = &json["absolute_ttl_remaining_secs"];
    assert!(
        field.is_null() || field.is_u64(),
        "`absolute_ttl_remaining_secs` must be null or a non-negative integer\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_agent_pid_is_null_or_u64() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    let field = &json["agent_pid"];
    assert!(
        field.is_null() || field.is_u64(),
        "`agent_pid` must be null or a non-negative integer\nfull output:\n{stdout}"
    );
}

// ── Version 1 null guarantees ─────────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_v1_session_expires_at_is_null() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["session_expires_at"].is_null(),
        "`session_expires_at` must be null in schema version 1 (requires future Status IPC variant)\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_v1_idle_ttl_remaining_secs_is_null() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["idle_ttl_remaining_secs"].is_null(),
        "`idle_ttl_remaining_secs` must be null in schema version 1\nfull output:\n{stdout}"
    );
}

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_v1_absolute_ttl_remaining_secs_is_null() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    assert!(
        json["absolute_ttl_remaining_secs"].is_null(),
        "`absolute_ttl_remaining_secs` must be null in schema version 1\nfull output:\n{stdout}"
    );
}

// ── All required fields present ───────────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_json_all_required_fields_present() {
    let (_, stdout, _) = agent_status_json("default");
    let json: Value = serde_json::from_str(&stdout).expect("valid JSON");
    let obj = json.as_object().expect("root must be a JSON object");

    let required_fields = [
        "version",
        "agent_running",
        "vault_locked",
        "active_profile",
        "biometric_enabled",
        "session_expires_at",
        "idle_ttl_remaining_secs",
        "absolute_ttl_remaining_secs",
        "agent_pid",
    ];

    for field in required_fields {
        assert!(
            obj.contains_key(field),
            "required field `{field}` is missing from agent status JSON\nfull output:\n{stdout}"
        );
    }
}

// ── Human-readable mode is unchanged ─────────────────────────────────────────

#[test]
#[cfg(feature = "agent")]
fn agent_status_human_output_is_not_json_when_json_flag_absent() {
    let dir = tempdir().unwrap();
    let output = tsafe()
        .args(["--profile", "default", "agent", "status"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env_remove("TSAFE_AGENT_SOCK")
        .env_remove("TSAFE_PROFILE")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    assert!(
        output.status.success(),
        "agent status (human mode) should exit 0\nstdout:\n{stdout}"
    );
    // Human output must not be valid JSON (it's a prose message).
    let parsed: Result<Value, _> = serde_json::from_str(&stdout);
    assert!(
        parsed.is_err(),
        "human-readable agent status should not produce valid JSON\nstdout:\n{stdout}"
    );
    assert!(
        stdout.contains("No agent running")
            || stdout.contains("Agent socket")
            || stdout.contains("Agent unreachable"),
        "human-readable agent status should describe agent reachability\nstdout:\n{stdout}"
    );
}