use super::{config::OpConnectConfig, error::OpConnectError};
#[derive(Debug, Clone)]
pub struct VaultRef {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct ItemRef {
pub id: String,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct OpField {
pub label: String,
pub value: String,
}
#[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()
}
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()),
}
}
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)
}
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)
}
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 })
}
#[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(),
}
}
#[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");
}
#[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");
}
#[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);
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"]);
}
#[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");
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");
}
#[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:?}"
);
}
#[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:?}"
);
}
#[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
);
}
}