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 gcp-push` (ADR-030 write contract, GCP provider).
//!
//! Uses a mockito server and the `TSAFE_GCP_TEST_LOCAL_URL` / `TSAFE_GCP_TEST_TOKEN` /
//! `TSAFE_GCP_TEST_PROJECT` env-var seam (debug builds only) to avoid real GCP auth.
//!
//! GCP Secret Manager write pattern:
//!   New secret  → POST /projects/{p}/secrets + POST /projects/{p}/secrets/{n}/versions:add
//!   Existing    → POST /projects/{p}/secrets/{n}/versions:add only

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

const TEST_PROJECT: &str = "test-project";

/// Build a `gcp-push` command pointing at a local mockito server.
fn gcp_push_cmd(server_url: &str) -> Command {
    let mut cmd = tsafe();
    cmd.arg("--profile")
        .arg("default")
        .env("TSAFE_GCP_TEST_LOCAL_URL", server_url)
        .env("TSAFE_GCP_TEST_TOKEN", "test-token")
        .env("TSAFE_GCP_TEST_PROJECT", TEST_PROJECT)
        // Remove real GCP env vars so the test seam is always used.
        .env_remove("GOOGLE_CLOUD_PROJECT")
        .env_remove("GCLOUD_PROJECT")
        .env_remove("GOOGLE_OAUTH_TOKEN")
        .env_remove("GOOGLE_APPLICATION_CREDENTIALS");
    cmd
}

/// Register a mock empty list endpoint (GCP format: `{"secrets": []}`).
fn mock_empty_remote(server: &mut Server) {
    server
        .mock(
            "GET",
            mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
        )
        .match_header("Authorization", "Bearer test-token")
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(r#"{"secrets":[]}"#)
        .create();
}

/// Encode a string value in standard base64 for use in mock GCP responses.
fn b64_encode(s: &str) -> String {
    use std::io::Write as _;
    // Simple base64 encoding matching what GCP uses.
    let data = s.as_bytes();
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::new();
    let mut i = 0;
    while i < data.len() {
        let b0 = data[i] as u32;
        let b1 = if i + 1 < data.len() {
            data[i + 1] as u32
        } else {
            0
        };
        let b2 = if i + 2 < data.len() {
            data[i + 2] as u32
        } else {
            0
        };
        out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char);
        out.push(CHARS[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
        out.push(if i + 1 < data.len() {
            CHARS[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char
        } else {
            '='
        });
        out.push(if i + 2 < data.len() {
            CHARS[(b2 & 0x3F) as usize] as char
        } else {
            '='
        });
        i += 3;
    }
    // suppress unused import warning
    let _ = std::io::sink().write_all(&[]);
    out
}

/// Register a remote with one existing secret (list + access-latest).
fn mock_remote_with_secret(server: &mut Server, name: &str, value: &str) {
    server
        .mock(
            "GET",
            mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"secrets":[{{"name":"projects/{TEST_PROJECT}/secrets/{name}"}}]}}"#
        ))
        .create();
    server
        .mock(
            "GET",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/{name}/versions/latest:access")
                    .to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"payload":{{"data":"{}"}}}}"#,
            b64_encode(value)
        ))
        .create();
}

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

#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_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);

    // No POST endpoints — any write attempt will fail the test.
    let no_create_mock = server
        .mock("POST", mockito::Matcher::Any)
        .expect(0)
        .create();

    gcp_push_cmd(&server.url())
        .args(["gcp-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_create_mock.assert();
}

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

#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_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);

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

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

    no_write_mock.assert();
}

// ── Test 3: new secret — two calls (create resource + add version) ────────────

#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_new_secret_uses_two_calls() {
    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);

    // GET to check existence → 404 (secret does not exist).
    let _existence_check = server
        .mock(
            "GET",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/db-password$").to_string(),
            ),
        )
        .with_status(404)
        .with_body(r#"{"error":{"code":404,"message":"Secret not found","status":"NOT_FOUND"}}"#)
        .expect(1)
        .create();

    // POST to create secret resource.
    let create_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?secretId=db-password$").to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"name":"projects/{TEST_PROJECT}/secrets/db-password"}}"#
        ))
        .expect(1)
        .create();

    // POST to add version.
    let version_add_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/db-password/versions:add$")
                    .to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"name":"projects/{TEST_PROJECT}/secrets/db-password/versions/1"}}"#
        ))
        .expect(1)
        .create();

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

    create_mock.assert();
    version_add_mock.assert();
}

// ── Test 4: existing secret — one call (add version only) ─────────────────────

#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_existing_secret_uses_one_call() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    // Store a different value from remote so it shows as an update.
    set_secret(dir.path(), "API_KEY", "new-value");

    let mut server = Server::new();
    // Remote has the same key with a different value → update path.
    mock_remote_with_secret(&mut server, "api-key", "old-value");

    // GET to check existence → 200 (secret already exists).
    let _existence_check = server
        .mock(
            "GET",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/api-key$").to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"name":"projects/{TEST_PROJECT}/secrets/api-key"}}"#
        ))
        .expect(1)
        .create();

    // No create-resource POST should happen.
    let no_create_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?").to_string()),
        )
        .expect(0)
        .create();

    // Only version-add POST should happen.
    let version_add_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/api-key/versions:add$").to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"name":"projects/{TEST_PROJECT}/secrets/api-key/versions/2"}}"#
        ))
        .expect(1)
        .create();

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

    no_create_mock.assert();
    version_add_mock.assert();
}

// ── Test 5: partial failure (create succeeds, version-add fails) ──────────────

#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_partial_failure_logged_when_version_add_fails() {
    let dir = tempdir().unwrap();
    init_vault(dir.path());
    set_secret(dir.path(), "BROKEN_SECRET", "value");

    let mut server = Server::new();
    mock_empty_remote(&mut server);

    // Existence check → 404 (new secret).
    let _existence_check = server
        .mock(
            "GET",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/broken-secret$").to_string(),
            ),
        )
        .with_status(404)
        .with_body(r#"{"error":{"code":404,"status":"NOT_FOUND"}}"#)
        .create();

    // Create resource succeeds.
    let _create_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets\?secretId=broken-secret$")
                    .to_string(),
            ),
        )
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(format!(
            r#"{{"name":"projects/{TEST_PROJECT}/secrets/broken-secret"}}"#
        ))
        .create();

    // Version-add fails with 500.
    let _version_fail_mock = server
        .mock(
            "POST",
            mockito::Matcher::Regex(
                format!(r"^/v1/projects/{TEST_PROJECT}/secrets/broken-secret/versions:add$")
                    .to_string(),
            ),
        )
        .with_status(500)
        .with_body(r#"{"error":{"code":500,"message":"Internal error","status":"INTERNAL"}}"#)
        .create();

    gcp_push_cmd(&server.url())
        .args(["gcp-push", "--yes"])
        .env("TSAFE_VAULT_DIR", dir.path())
        .env("TSAFE_PASSWORD", "test-pw")
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("failed to create 'broken-secret'")
                .or(predicate::str::contains("500")),
        );
}