tsafe-cli 1.0.28

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
Documentation
//! Integration tests for `tsafe kv-push` (ADR-030 write contract).
//!
//! Uses the same local-test-seam pattern as `test_pull.rs`:
//! `TSAFE_AKV_TEST_LOCAL_URL` + `TSAFE_AKV_TEST_TOKEN` in debug builds
//! bypass real Azure auth so tests run against a mockito server.

use assert_cmd::Command;
use mockito::Server;
use predicates::prelude::*;
use tempfile::tempdir;

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

fn init_vault(dir: &std::path::Path) {
    tsafe()
        .args(["--profile", "default", "init"])
        .env("TSAFE_VAULT_DIR", dir)
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .success();
}

fn set_secret(dir: &std::path::Path, key: &str, value: &str) {
    tsafe()
        .args(["--profile", "default", "set", key, value])
        .env("TSAFE_VAULT_DIR", dir)
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .success();
}

/// Build a `kv-push` command that points at a local mockito server.
fn kv_push_local_test_cmd(server_url: &str) -> Command {
    let mut cmd = tsafe();
    cmd.arg("--profile")
        .arg("default")
        .env("TSAFE_AKV_TEST_LOCAL_URL", server_url)
        .env("TSAFE_AKV_TEST_TOKEN", "tok")
        .env_remove("TSAFE_AKV_URL")
        .env_remove("AZURE_TENANT_ID")
        .env_remove("AZURE_CLIENT_ID")
        .env_remove("AZURE_CLIENT_SECRET");
    cmd
}

/// Register a mock empty list endpoint so `pull_secrets` (used for remote fetch) returns nothing.
fn mock_empty_remote(server: &mut Server) {
    server
        .mock("GET", mockito::Matcher::Regex(r"^/secrets\?".to_string()))
        .match_header("Authorization", "Bearer tok")
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(r#"{"value":[]}"#)
        .create();
}

/// Register an existing secret in the remote (for the list + get path used in pull_secrets).
fn mock_remote_with_secret(server: &mut Server, name: &str, value: &str) {
    // List endpoint returns one secret.
    server
        .mock("GET", mockito::Matcher::Regex(r"^/secrets\?".to_string()))
        .match_header("Authorization", "Bearer tok")
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"value":[{{"id":"https://vault/secrets/{name}","attributes":{{"enabled":true}}}}]}}"#
        ))
        .create();
    // Get endpoint returns the secret value.
    server
        .mock(
            "GET",
            mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
        )
        .match_header("Authorization", "Bearer tok")
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(r#"{{"value":"{value}"}}"#))
        .create();
}

/// Register a PUT endpoint that accepts a secret and returns success.
/// Also mocks the preceding GET (which `push_secret` uses to check the current value)
/// to return 404 — simulating a secret absent from the remote vault.
fn mock_put_secret(server: &mut Server, name: &str) -> mockito::Mock {
    // GET returns 404 so push_secret detects the secret as absent (create path).
    server
        .mock(
            "GET",
            mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
        )
        .match_header("Authorization", "Bearer tok")
        .with_status(404)
        .with_header("Content-Type", "application/json")
        .with_body(r#"{"error":{"code":"SecretNotFound"}}"#)
        .create();
    // PUT creates the secret.
    server
        .mock(
            "PUT",
            mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
        )
        .match_header("Authorization", "Bearer tok")
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"id":"https://vault/secrets/{name}/v1","value":"secret"}}"#
        ))
        .create()
}

// ── Test 1: dry-run shows diff without writing ────────────────────────────────

#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_dry_run_shows_diff_without_writing() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    set_secret(dir.path(), "MY_SECRET", "hello");

    let mut server = Server::new();
    mock_empty_remote(&mut server);
    // Deliberately do NOT register a PUT mock — if any write is made the test
    // will fail with "unexpected call" or "no mock matched" from mockito.
    let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();

    kv_push_local_test_cmd(&server.url())
        .args(["kv-push", "--dry-run"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .success()
        .stdout(predicate::str::contains("create").or(predicate::str::contains("update")))
        .stdout(predicate::str::contains("Dry-run complete"));

    no_put_mock.assert(); // 0 PUT calls made
}

// ── Test 2: abort without --yes in non-interactive ───────────────────────────

#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_aborts_without_yes_in_non_interactive() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    set_secret(dir.path(), "SOME_KEY", "value123");

    let mut server = Server::new();
    mock_empty_remote(&mut server);
    // No PUT should be made.
    let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();

    // stdin is not a TTY in test processes; without --yes this must fail.
    kv_push_local_test_cmd(&server.url())
        .args(["kv-push"]) // no --yes
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .failure()
        .stderr(predicate::str::contains("--yes"));

    no_put_mock.assert();
}

// ── Test 3: creates new secrets when remote is empty ─────────────────────────

#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_creates_new_secrets() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    set_secret(dir.path(), "DB_PASSWORD", "s3cr3t");

    let mut server = Server::new();
    mock_empty_remote(&mut server);
    let put_mock = mock_put_secret(&mut server, "db-password");

    kv_push_local_test_cmd(&server.url())
        .args(["kv-push", "--yes"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .success()
        .stdout(predicate::str::contains("created"));

    put_mock.assert(); // exactly 1 PUT to /secrets/db-password
}

// ── Test 4: collision detection aborts before any write ──────────────────────

#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_collision_detection_aborts_before_any_write() {
    // MY_SECRET and MY-SECRET (if local vault allowed hyphen in key) would both
    // normalise to "my-secret". We test with two keys that both normalise the same way.
    // Use a namespace that has two keys mapping to the same provider name:
    // e.g. MY_KEY and MY__KEY → "my-key" and "my--key" are different.
    // The actual collision case: store a key whose normalized form matches another.
    // Since local vault key names are typically UPPER_SNAKE, let's test the scenario
    // via a namespace: ns/MY_SECRET and ns/MY-SECRET — but vault keys can't easily
    // have hyphens. Instead, test with "API_KEY" and "API-KEY" stored both as
    // normal vault keys if vault allows it.
    //
    // The simplest reproducible collision in our normalization:
    // "MY_DB_PASS" → "my-db-pass"
    // There is no collision with a single unique key; we need two that map the same.
    //
    // Our normalization: replace '_' with '-', lowercase.
    // So "MY__SECRET" → "my--secret" (still distinct from "my-secret").
    // A real collision requires two local keys where after replace+lowercase they match.
    // Example: "MYKEY" → "mykey", "mykey" → "mykey" — but vault key names are case-sensitive.
    // Actually, two separate keys stored as "MY_KEY" and "MY_KEY" is impossible (same key).
    //
    // The collision can only happen if the *normalized* result is identical.
    // Given normalization = replace('_', '-').to_lowercase():
    //   "MY_KEY" → "my-key"
    //   The only way to collide is via case: local vault stores "MY_KEY" and "my_key".
    //   Both normalize to "my-key".
    let dir = tempdir().unwrap();
    init_vault(dir.path());

    // Store two keys that normalize to the same provider name.
    // "MY_KEY" and "my_key" both become "my-key" after our reverse-normalization.
    set_secret(dir.path(), "MY_KEY", "value-a");
    set_secret(dir.path(), "my_key", "value-b");

    let mut server = Server::new();
    mock_empty_remote(&mut server);
    let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();

    kv_push_local_test_cmd(&server.url())
        .args(["kv-push", "--yes"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .failure()
        .stderr(predicate::str::contains("collision").or(predicate::str::contains("my-key")));

    no_put_mock.assert(); // no writes made before collision detection
}

// ── Test 5: unchanged secrets produce no writes ───────────────────────────────

#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_unchanged_secrets_produce_no_write_calls() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    set_secret(dir.path(), "API_TOKEN", "identical-value");

    let mut server = Server::new();
    // Remote has the same secret with the same value (normalized key "api-token").
    mock_remote_with_secret(&mut server, "api-token", "identical-value");

    let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();

    kv_push_local_test_cmd(&server.url())
        .args(["kv-push", "--yes"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .success()
        .stdout(predicate::str::contains("unchanged").or(predicate::str::contains("up to date")));

    no_put_mock.assert(); // no write calls for identical value
}