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 aws-push` (ADR-030 write contract).
//!
//! Uses mockito to stand in for AWS Secrets Manager. The `aws-push` command
//! uses `pull_secrets` (ListSecrets + GetSecretValue) for the remote fetch and
//! `push_secret` (GetSecretValue + CreateSecret/PutSecretValue) for writes.
//!
//! All tests run only when the `cloud-pull-aws` feature is active.

#[cfg(feature = "cloud-pull-aws")]
mod inner {
    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 an `aws-push` command pointing at a local mockito server.
    fn aws_push_cmd(server_url: &str, dir: &std::path::Path) -> Command {
        let mut cmd = tsafe();
        cmd.args(["--profile", "default"])
            .env("TSAFE_VAULT_DIR", dir)
            .env("TSAFE_PASSWORD", "test-pw")
            .env("AWS_DEFAULT_REGION", "us-east-1")
            .env("AWS_ACCESS_KEY_ID", "AKID-TEST")
            .env("AWS_SECRET_ACCESS_KEY", "secret-test")
            .env_remove("AWS_SESSION_TOKEN")
            // Override the Secrets Manager endpoint to the mockito server.
            // cmd_aws_push builds the endpoint as https://secretsmanager.<region>.amazonaws.com;
            // we need to point it at the mock. We do this via a dedicated test env var.
            .env("TSAFE_AWS_SM_TEST_ENDPOINT", server_url);
        cmd
    }

    /// Register an empty ListSecrets response (no remote secrets).
    fn mock_empty_list(server: &mut Server) {
        server
            .mock("POST", "/")
            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
            .with_status(200)
            .with_header("Content-Type", "application/x-amz-json-1.1")
            .with_body(r#"{"SecretList":[]}"#)
            .create();
    }

    /// Register a ListSecrets response with one secret, plus a GetSecretValue for it.
    fn mock_remote_secret(server: &mut Server, name: &str, value: &str) {
        server
            .mock("POST", "/")
            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
            .with_status(200)
            .with_header("Content-Type", "application/x-amz-json-1.1")
            .with_body(format!(r#"{{"SecretList":[{{"Name":"{name}","ARN":"arn:aws:secretsmanager:us-east-1:123:secret:{name}"}}]}}"#))
            .create();
        server
            .mock("POST", "/")
            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
            .with_status(200)
            .with_header("Content-Type", "application/x-amz-json-1.1")
            .with_body(format!(r#"{{"Name":"{name}","SecretString":"{value}"}}"#))
            .create();
    }

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

    #[test]
    fn aws_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_list(&mut server);
        // No GetSecretValue (create path) or CreateSecret mock — writes would fail.
        let no_write = server
            .mock("POST", "/")
            .match_header(
                "X-Amz-Target",
                mockito::Matcher::Regex("(CreateSecret|PutSecretValue)".to_string()),
            )
            .expect(0)
            .create();

        aws_push_cmd(&server.url(), dir.path())
            .args(["aws-push", "--dry-run"])
            .assert()
            .success()
            .stdout(predicate::str::contains("create").or(predicate::str::contains("update")))
            .stdout(predicate::str::contains("Dry-run complete"));

        no_write.assert();
    }

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

    #[test]
    fn aws_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_list(&mut server);
        let no_write = server
            .mock("POST", "/")
            .match_header(
                "X-Amz-Target",
                mockito::Matcher::Regex("(CreateSecret|PutSecretValue)".to_string()),
            )
            .expect(0)
            .create();

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

        no_write.assert();
    }

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

    #[test]
    fn aws_push_collision_detection_aborts_before_write() {
        let dir = tempdir().unwrap();
        init_vault(dir.path());
        // MY_KEY and my_key both normalize to "my-key" → collision.
        set_secret(dir.path(), "MY_KEY", "value-a");
        set_secret(dir.path(), "my_key", "value-b");

        let mut server = Server::new();
        mock_empty_list(&mut server);
        let no_write = server
            .mock("POST", "/")
            .match_header(
                "X-Amz-Target",
                mockito::Matcher::Regex("(CreateSecret|PutSecretValue)".to_string()),
            )
            .expect(0)
            .create();

        aws_push_cmd(&server.url(), dir.path())
            .args(["aws-push", "--yes"])
            .assert()
            .failure()
            .stderr(predicate::str::contains("collision").or(predicate::str::contains("my-key")));

        no_write.assert();
    }

    // ── Test: unchanged secrets produce no write calls ────────────────────────

    #[test]
    fn aws_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 api-token with the same value.
        mock_remote_secret(&mut server, "api-token", "identical-value");
        let no_write = server
            .mock("POST", "/")
            .match_header(
                "X-Amz-Target",
                mockito::Matcher::Regex("(CreateSecret|PutSecretValue)".to_string()),
            )
            .expect(0)
            .create();

        aws_push_cmd(&server.url(), dir.path())
            .args(["aws-push", "--yes"])
            .assert()
            .success()
            .stdout(
                predicate::str::contains("unchanged").or(predicate::str::contains("up to date")),
            );

        no_write.assert();
    }
}