tsafe-cli 1.0.27

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! 1Password Connect Server REST API client (read-only, Tranche 2).
//!
//! Implements the three endpoints consumed by `cmd_op_pull_ns_connect`:
//! - `GET /v1/vaults`                              — list vaults
//! - `GET /v1/vaults/{vaultUUID}/items`            — list items in a vault
//! - `GET /v1/vaults/{vaultUUID}/items/{itemUUID}` — get item with fields
//!
//! Auth: `Authorization: Bearer <token>` on every request.
//!
//! The item `fields[]` shape is identical to `op item get --format json` output,
//! so the consumer schema at `contracts/integrations/op-item.consumer.schema.json`
//! applies unchanged.

use super::{config::OpConnectConfig, error::OpConnectError};

/// Lightweight reference to a vault (id + name) from the list endpoint.
#[derive(Debug, Clone)]
pub struct VaultRef {
    pub id: String,
    pub name: String,
}

/// Lightweight reference to an item (id + title) from the list endpoint.
#[derive(Debug, Clone)]
pub struct ItemRef {
    pub id: String,
    pub title: String,
}

/// A field from an item's `fields` array.
#[derive(Debug, Clone)]
pub struct OpField {
    pub label: String,
    pub value: String,
}

/// Full item returned by `GET /v1/vaults/{vaultUUID}/items/{itemUUID}`.
#[derive(Debug, Clone)]
pub struct OpItem {
    pub id: String,
    pub title: String,
    pub fields: Vec<OpField>,
}

fn build_agent() -> ureq::Agent {
    ureq::AgentBuilder::new()
        .timeout_connect(std::time::Duration::from_secs(10))
        .timeout_read(std::time::Duration::from_secs(30))
        .timeout_write(std::time::Duration::from_secs(30))
        .build()
}

/// Map a ureq transport or HTTP error to `OpConnectError`.
fn classify_ureq_error(e: ureq::Error) -> OpConnectError {
    match e {
        ureq::Error::Status(401, resp) => {
            let body = resp.into_string().unwrap_or_default();
            OpConnectError::Auth(format!(
                "Connect Server returned 401 — token is invalid or revoked: {body}"
            ))
        }
        ureq::Error::Status(404, resp) => {
            let body = resp.into_string().unwrap_or_default();
            OpConnectError::Http { status: 404, body }
        }
        ureq::Error::Status(status, resp) => {
            let body = resp.into_string().unwrap_or_default();
            OpConnectError::Http { status, body }
        }
        ureq::Error::Transport(t) => OpConnectError::Transport(t.to_string()),
    }
}

/// List all vaults visible to the Connect token.
///
/// Connect API: `GET {connect_url}/v1/vaults`
/// Response shape: `[{"id": "...", "name": "..."}]`
pub fn list_vaults(cfg: &OpConnectConfig) -> Result<Vec<VaultRef>, OpConnectError> {
    let agent = build_agent();
    let url = format!("{}/v1/vaults", cfg.connect_url);

    let resp: serde_json::Value = agent
        .get(&url)
        .set("Authorization", &format!("Bearer {}", cfg.token))
        .call()
        .map_err(classify_ureq_error)?
        .into_json()
        .map_err(|e| OpConnectError::Transport(format!("failed to parse vault list: {e}")))?;

    let arr = resp.as_array().ok_or_else(|| {
        OpConnectError::Transport("vault list response is not a JSON array".into())
    })?;

    let vaults = arr
        .iter()
        .filter_map(|v| {
            let id = v["id"].as_str()?.to_string();
            let name = v["name"].as_str()?.to_string();
            Some(VaultRef { id, name })
        })
        .collect();

    Ok(vaults)
}

/// List all items in `vault_uuid`.
///
/// Connect API: `GET {connect_url}/v1/vaults/{vaultUUID}/items`
/// Response shape: `[{"id": "...", "title": "..."}]`
pub fn list_items(cfg: &OpConnectConfig, vault_uuid: &str) -> Result<Vec<ItemRef>, OpConnectError> {
    let agent = build_agent();
    let url = format!("{}/v1/vaults/{vault_uuid}/items", cfg.connect_url);

    let resp: serde_json::Value = agent
        .get(&url)
        .set("Authorization", &format!("Bearer {}", cfg.token))
        .call()
        .map_err(classify_ureq_error)?
        .into_json()
        .map_err(|e| OpConnectError::Transport(format!("failed to parse item list: {e}")))?;

    let arr = resp.as_array().ok_or_else(|| {
        OpConnectError::Transport("item list response is not a JSON array".into())
    })?;

    let items = arr
        .iter()
        .filter_map(|v| {
            let id = v["id"].as_str()?.to_string();
            let title = v["title"].as_str()?.to_string();
            Some(ItemRef { id, title })
        })
        .collect();

    Ok(items)
}

/// Fetch a full item (with `fields`) by vault UUID and item UUID.
///
/// Connect API: `GET {connect_url}/v1/vaults/{vaultUUID}/items/{itemUUID}`
/// Response shape mirrors `op item get --format json`:
/// `{"id":"...","title":"...","fields":[{"label":"...","value":"..."}]}`
pub fn get_item(
    cfg: &OpConnectConfig,
    vault_uuid: &str,
    item_uuid: &str,
) -> Result<OpItem, OpConnectError> {
    let agent = build_agent();
    let url = format!(
        "{}/v1/vaults/{vault_uuid}/items/{item_uuid}",
        cfg.connect_url
    );

    let resp: serde_json::Value = agent
        .get(&url)
        .set("Authorization", &format!("Bearer {}", cfg.token))
        .call()
        .map_err(classify_ureq_error)?
        .into_json()
        .map_err(|e| OpConnectError::Transport(format!("failed to parse item: {e}")))?;

    let id = resp["id"]
        .as_str()
        .ok_or_else(|| OpConnectError::Transport("item response missing 'id'".into()))?
        .to_string();

    let title = resp["title"]
        .as_str()
        .ok_or_else(|| OpConnectError::Transport("item response missing 'title'".into()))?
        .to_string();

    let fields = resp["fields"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .map(|f| {
                    let label = f["label"].as_str().unwrap_or_default().to_string();
                    let value = f["value"].as_str().unwrap_or_default().to_string();
                    OpField { label, value }
                })
                .collect()
        })
        .unwrap_or_default();

    Ok(OpItem { id, title, fields })
}

// ── tests ──────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn test_cfg(url: &str) -> OpConnectConfig {
        OpConnectConfig {
            connect_url: url.trim_end_matches('/').to_string(),
            token: "test-token".to_string(),
        }
    }

    /// `GET /v1/vaults` returns a vault list that is correctly 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":"vault-uuid-1","name":"Personal"},{"id":"vault-uuid-2","name":"Work"}]"#,
            )
            .create();

        let cfg = test_cfg(&server.url());
        let vaults = list_vaults(&cfg).expect("list_vaults should succeed");
        assert_eq!(vaults.len(), 2);
        assert_eq!(vaults[0].id, "vault-uuid-1");
        assert_eq!(vaults[0].name, "Personal");
        assert_eq!(vaults[1].id, "vault-uuid-2");
        assert_eq!(vaults[1].name, "Work");
    }

    /// `GET /v1/vaults/{uuid}/items` returns an item list that is correctly parsed.
    #[test]
    fn list_items_parses_response() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults/vault-abc/items")
            .match_header("authorization", "Bearer test-token")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(
                r#"[{"id":"item-uuid-1","title":"Database Credentials"},{"id":"item-uuid-2","title":"API Keys"}]"#,
            )
            .create();

        let cfg = test_cfg(&server.url());
        let items = list_items(&cfg, "vault-abc").expect("list_items should succeed");
        assert_eq!(items.len(), 2);
        assert_eq!(items[0].id, "item-uuid-1");
        assert_eq!(items[0].title, "Database Credentials");
    }

    /// `GET /v1/vaults/{uuid}/items/{uuid}` returns an item with fields.
    /// Verifies that field labels and values are extracted correctly.
    #[test]
    fn get_item_field_extraction() {
        let mut server = mockito::Server::new();

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

        let cfg = test_cfg(&server.url());
        let item = get_item(&cfg, "vault-abc", "item-xyz").expect("get_item should succeed");

        assert_eq!(item.id, "item-xyz");
        assert_eq!(item.title, "Database Credentials");
        assert_eq!(item.fields.len(), 3);

        // Verify field mapping matches ADR-013: field-label only, no item prefix.
        let keys: Vec<String> = item
            .fields
            .iter()
            .map(|f| crate::op_mapping::op_field_label_to_key(&f.label))
            .collect();
        assert_eq!(keys, vec!["USERNAME", "PASSWORD", "DATABASE_HOST"]);
    }

    /// Fields with empty values must not appear in the derived key set.
    #[test]
    fn get_item_empty_fields_skipped() {
        let mut server = mockito::Server::new();

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

        let cfg = test_cfg(&server.url());
        let item = get_item(&cfg, "vault-abc", "item-empty").expect("get_item should succeed");

        // Filter as cmd_op_pull_ns_connect does: skip empty value.
        let non_empty: Vec<&OpField> = item.fields.iter().filter(|f| !f.value.is_empty()).collect();
        assert_eq!(non_empty.len(), 1);
        assert_eq!(non_empty[0].label, "API Key");
        assert_eq!(non_empty[0].value, "actual-key");
    }

    /// A 401 from the Connect Server maps to `OpConnectError::Auth`.
    #[test]
    fn list_vaults_401_returns_auth_error() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults")
            .with_status(401)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"status":401,"message":"Invalid token"}"#)
            .create();

        let cfg = test_cfg(&server.url());
        let result = list_vaults(&cfg);
        assert!(
            matches!(result, Err(OpConnectError::Auth(_))),
            "expected Auth error for 401, got: {result:?}"
        );
    }

    /// An unexpected HTTP status maps to `OpConnectError::Http`.
    #[test]
    fn list_vaults_500_returns_http_error() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults")
            .with_status(500)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"status":500,"message":"Internal server error"}"#)
            .create();

        let cfg = test_cfg(&server.url());
        let result = list_vaults(&cfg);
        assert!(
            matches!(result, Err(OpConnectError::Http { status: 500, .. })),
            "expected Http error for 500, got: {result:?}"
        );
    }

    /// An item with no `fields` key returns an empty fields vec (no panic).
    #[test]
    fn get_item_missing_fields_array_returns_empty_vec() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/v1/vaults/v/items/i")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"id":"i","title":"No Fields Item"}"#)
            .create();

        let cfg = test_cfg(&server.url());
        let item = get_item(&cfg, "v", "i").expect("get_item should succeed");
        assert!(
            item.fields.is_empty(),
            "expected empty fields, got: {:?}",
            item.fields
        );
    }
}