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 the 1Password Connect Server HTTP client (task E2.1).
//!
//! These tests verify:
//! - `OpConnectConfig::from_env` env-var requirements and HTTPS posture
//! - `client::list_vaults` response parsing
//! - `client::get_item` field extraction and ADR-013 key mapping
//! - Empty-field filtering (consumer schema: "absent or empty-string entries are skipped")
//!
//! All HTTP calls use mockito — no real Connect Server is required.
//! The `op` binary is never invoked by these tests.

#[cfg(feature = "cloud-pull-1password")]
mod connect_tests {
    use tsafe_cli::tsafe_op::{client, config::OpConnectConfig};

    // Helper: build a config pointing at the given mock server URL.
    fn cfg(url: &str) -> OpConnectConfig {
        OpConnectConfig {
            connect_url: url.trim_end_matches('/').to_string(),
            token: "test-token".into(),
        }
    }

    // ── Config env-var validation ─────────────────────────────────────────────

    /// Only `OP_CONNECT_URL` set (token absent) → `Err(Config)`.
    #[test]
    fn connect_config_from_env_requires_both_vars_url_only() {
        use tsafe_cli::tsafe_op::error::OpConnectError;

        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("https://connect.example.com")),
                ("OP_CONNECT_TOKEN", None::<&str>),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error when token absent, got: {result:?}"
                );
            },
        );
    }

    /// Only `OP_CONNECT_TOKEN` set (URL absent) → `Err(Config)`.
    #[test]
    fn connect_config_from_env_requires_both_vars_token_only() {
        use tsafe_cli::tsafe_op::error::OpConnectError;

        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", None::<&str>),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error when URL absent, got: {result:?}"
                );
            },
        );
    }

    /// `OP_CONNECT_URL=http://remote.example.com` → `Err(Config)`.
    #[test]
    fn connect_config_rejects_http_non_localhost() {
        use tsafe_cli::tsafe_op::error::OpConnectError;

        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("http://remote.example.com")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error for non-localhost plain HTTP, got: {result:?}"
                );
            },
        );
    }

    /// `OP_CONNECT_URL=http://localhost:8080` → `Ok` (local dev allowed).
    #[test]
    fn connect_config_allows_http_localhost() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("http://localhost:8080")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    result.is_ok(),
                    "expected Ok for localhost HTTP, got: {result:?}"
                );
                let cfg = result.unwrap();
                assert_eq!(cfg.connect_url, "http://localhost:8080");
            },
        );
    }

    // ── client::list_vaults ───────────────────────────────────────────────────

    /// Mock server returns a two-element vault list — asserts id and name parsed.
    #[test]
    fn list_vaults_parses_response() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults")
            .match_header("authorization", "Bearer test-token")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(r#"[{"id":"v1","name":"Personal"},{"id":"v2","name":"Work"}]"#)
            .create();

        let vaults = client::list_vaults(&cfg(&server.url())).expect("list_vaults should succeed");
        assert_eq!(vaults.len(), 2);
        assert_eq!(vaults[0].id, "v1");
        assert_eq!(vaults[0].name, "Personal");
        assert_eq!(vaults[1].id, "v2");
        assert_eq!(vaults[1].name, "Work");
    }

    // ── client::get_item field extraction ─────────────────────────────────────

    /// Mock server returns an item — asserts that field labels map to the correct
    /// ADR-013 keys (field-label only, spaces/hyphens → underscores, uppercase).
    #[test]
    fn get_item_field_extraction() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults/v-abc/items/i-xyz")
            .match_header("authorization", "Bearer test-token")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(
                r#"{
                    "id":"i-xyz",
                    "title":"DB Creds",
                    "fields":[
                        {"label":"username","value":"admin"},
                        {"label":"database host","value":"db.example.com"},
                        {"label":"API-Key","value":"xyz123"}
                    ]
                }"#,
            )
            .create();

        let item = client::get_item(&cfg(&server.url()), "v-abc", "i-xyz")
            .expect("get_item should succeed");

        let keys: Vec<String> = item
            .fields
            .iter()
            .map(|f| tsafe_cli::op_mapping::op_field_label_to_key(&f.label))
            .collect();

        // ADR-013 §Part 1: field-label only, spaces and hyphens → underscores, uppercase.
        assert_eq!(keys, vec!["USERNAME", "DATABASE_HOST", "API_KEY"]);
    }

    /// Fields with empty value must be excluded from the candidate set.
    #[test]
    fn get_item_empty_fields_skipped() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults/v/items/i")
            .match_header("authorization", "Bearer test-token")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(
                r#"{
                    "id":"i",
                    "title":"Mixed",
                    "fields":[
                        {"label":"API Key","value":"the-real-key"},
                        {"label":"notes","value":""},
                        {"label":"absent-value"}
                    ]
                }"#,
            )
            .create();

        let item =
            client::get_item(&cfg(&server.url()), "v", "i").expect("get_item should succeed");

        let non_empty: Vec<_> = item.fields.iter().filter(|f| !f.value.is_empty()).collect();
        assert_eq!(non_empty.len(), 1, "only one non-empty field expected");
        assert_eq!(non_empty[0].label, "API Key");
        assert_eq!(
            tsafe_cli::op_mapping::op_field_label_to_key(&non_empty[0].label),
            "API_KEY"
        );
    }
}