Skip to main content

cuenv_1password/secrets/
resolver.rs

1//! 1Password secret resolver with auto-negotiating dual-mode (HTTP via WASM SDK + CLI)
2
3// Complex WASM+CLI dual-mode resolver with mutex-based shared core management
4#![allow(
5    clippy::cognitive_complexity,
6    clippy::too_many_lines,
7    clippy::significant_drop_tightening
8)]
9
10use super::core::SharedCore;
11use super::wasm;
12use async_trait::async_trait;
13use cuenv_secrets::{SecretError, SecretResolver, SecretSpec, SecureSecret};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use tokio::{process::Command, sync::Mutex};
17
18/// Configuration for 1Password secret resolution
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub struct OnePasswordConfig {
22    /// Secret reference (e.g., `op://vault/item/field`)
23    #[serde(rename = "ref")]
24    pub reference: String,
25}
26
27impl OnePasswordConfig {
28    /// Create a new 1Password secret config
29    #[must_use]
30    pub fn new(reference: impl Into<String>) -> Self {
31        Self {
32            reference: reference.into(),
33        }
34    }
35}
36
37/// Resolves secrets from 1Password
38///
39/// Mode is auto-negotiated based on environment:
40/// - If `OP_SERVICE_ACCOUNT_TOKEN` is set AND WASM SDK is installed → HTTP mode
41/// - Otherwise → CLI mode (uses `op` CLI)
42///
43/// To enable HTTP mode, run: `cuenv secrets setup onepassword`
44///
45/// The `source` field in [`SecretSpec`] can be:
46/// - A JSON-encoded [`OnePasswordConfig`]
47/// - A simple reference string (e.g., `op://vault/item/field`)
48pub struct OnePasswordResolver {
49    /// Client ID for WASM SDK (when using HTTP mode)
50    client_id: Option<u64>,
51    /// One-time CLI auth preflight state for `op whoami` in CLI mode.
52    cli_auth_state: Mutex<CliAuthState>,
53}
54
55#[derive(Debug, Clone)]
56enum CliAuthState {
57    Unknown,
58    Authenticated,
59    Failed(String),
60}
61
62impl std::fmt::Debug for OnePasswordResolver {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("OnePasswordResolver")
65            .field("mode", &if self.can_use_http() { "http" } else { "cli" })
66            .finish()
67    }
68}
69
70impl OnePasswordResolver {
71    /// Create a new 1Password resolver with auto-detected mode
72    ///
73    /// If 1Password service account token is available AND the WASM SDK is installed,
74    /// uses HTTP mode. Otherwise, CLI mode will be used.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if HTTP mode is detected (WASM + token) but WASM initialization fails.
79    /// This prevents silent fallback to CLI mode which masks configuration errors.
80    pub fn new() -> Result<Self, SecretError> {
81        let client_id = if Self::http_mode_available() {
82            // HTTP mode is available (WASM + token), WASM MUST initialize successfully
83            // Do NOT silently fall back to CLI - that masks the real error
84            let id = Self::init_wasm_client().map_err(|e| SecretError::ResolutionFailed {
85                name: "onepassword".to_string(),
86                message: format!(
87                    "1Password HTTP mode detected (WASM + token) but initialization failed: {e}\n\
88                    \n\
89                    This indicates a platform/runtime compatibility issue.\n\
90                    To use CLI mode instead, unset OP_SERVICE_ACCOUNT_TOKEN or remove the WASM file."
91                ),
92            })?;
93            tracing::debug!("1Password WASM client initialized successfully");
94            Some(id)
95        } else {
96            tracing::debug!("1Password HTTP mode not available, using CLI");
97            None
98        };
99
100        Ok(Self {
101            client_id,
102            cli_auth_state: Mutex::new(CliAuthState::Unknown),
103        })
104    }
105
106    /// Check if HTTP mode is available (token set + WASM installed)
107    fn http_mode_available() -> bool {
108        let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
109        let wasm_available = wasm::onepassword_wasm_available();
110        tracing::trace!(
111            token_set,
112            wasm_available,
113            "1Password HTTP mode availability check"
114        );
115        token_set && wasm_available
116    }
117
118    /// Initialize the WASM client and return the client ID
119    fn init_wasm_client() -> Result<u64, SecretError> {
120        let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
121            SecretError::ResolutionFailed {
122                name: "onepassword".to_string(),
123                message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
124            }
125        })?;
126
127        let core_mutex = SharedCore::get_or_init()?;
128        let mut guard = core_mutex
129            .lock()
130            .map_err(|_| SecretError::ResolutionFailed {
131                name: "onepassword".to_string(),
132                message: "Failed to acquire shared core lock".to_string(),
133            })?;
134
135        let core = guard
136            .as_mut()
137            .ok_or_else(|| SecretError::ResolutionFailed {
138                name: "onepassword".to_string(),
139                message: "SharedCore not initialized".to_string(),
140            })?;
141
142        core.init_client(&token)
143    }
144
145    /// Check if this resolver can use HTTP mode
146    const fn can_use_http(&self) -> bool {
147        self.client_id.is_some()
148    }
149
150    /// Resolve using the 1Password WASM SDK (HTTP mode)
151    fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
152        let client_id = self
153            .client_id
154            .ok_or_else(|| SecretError::ResolutionFailed {
155                name: name.to_string(),
156                message: "HTTP client not initialized".to_string(),
157            })?;
158
159        let core_mutex = SharedCore::get_or_init()?;
160        let mut guard = core_mutex
161            .lock()
162            .map_err(|_| SecretError::ResolutionFailed {
163                name: name.to_string(),
164                message: "Failed to acquire shared core lock".to_string(),
165            })?;
166
167        let core = guard
168            .as_mut()
169            .ok_or_else(|| SecretError::ResolutionFailed {
170                name: name.to_string(),
171                message: "SharedCore not initialized".to_string(),
172            })?;
173
174        // Invoke the SecretsResolve method (Go SDK uses this name, not "Secrets.Resolve")
175        let mut params = serde_json::Map::new();
176        params.insert(
177            "secret_reference".to_string(),
178            serde_json::Value::String(config.reference.clone()),
179        );
180
181        let result = core.invoke(client_id, "SecretsResolve", &params, &config.reference)?;
182
183        // Parse the response - the Go SDK returns a JSON-encoded string
184        // The invoke response is the raw string from WASM, which is a JSON-quoted secret value
185        let secret: String =
186            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
187                name: name.to_string(),
188                message: format!("Failed to parse resolve response: {e}"),
189            })?;
190
191        Ok(secret)
192    }
193
194    /// Resolve using the op CLI
195    async fn resolve_cli(
196        &self,
197        name: &str,
198        config: &OnePasswordConfig,
199    ) -> Result<String, SecretError> {
200        tracing::debug!(
201            name = name,
202            reference = config.reference,
203            "1Password resolve_cli"
204        );
205        let output = Command::new("op")
206            .args(["read", &config.reference])
207            .output()
208            .await
209            .map_err(|e| SecretError::ResolutionFailed {
210                name: name.to_string(),
211                message: format!("Failed to execute op CLI: {e}"),
212            })?;
213
214        if !output.status.success() {
215            let stderr = String::from_utf8_lossy(&output.stderr);
216            return Err(SecretError::ResolutionFailed {
217                name: name.to_string(),
218                message: format!("op CLI failed: {stderr}"),
219            });
220        }
221
222        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
223    }
224
225    /// Ensure CLI auth is valid once per resolver instance.
226    ///
227    /// Returns `Some(secret_value)` when auth bootstrap consumed the current
228    /// secret read, allowing caller to skip a second `op read` call.
229    async fn ensure_cli_authenticated(
230        &self,
231        name: &str,
232        config: &OnePasswordConfig,
233    ) -> Result<Option<String>, SecretError> {
234        let mut state = self.cli_auth_state.lock().await;
235        match &*state {
236            CliAuthState::Authenticated => return Ok(None),
237            CliAuthState::Failed(message) => {
238                return Err(SecretError::ResolutionFailed {
239                    name: name.to_string(),
240                    message: message.clone(),
241                });
242            }
243            CliAuthState::Unknown => {}
244        }
245
246        let preflight_result = Command::new("op").arg("whoami").output().await;
247
248        match preflight_result {
249            Ok(output) if output.status.success() => {
250                *state = CliAuthState::Authenticated;
251                Ok(None)
252            }
253            Ok(output) => {
254                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
255                let details = if stderr.is_empty() {
256                    "no error output from 1Password CLI".to_string()
257                } else {
258                    stderr
259                };
260
261                // Interactive local workflows may be signed out before the first read.
262                // In this case, allow one bootstrap read to trigger signin once,
263                // then keep all remaining reads parallel.
264                if details.to_lowercase().contains("not signed in") {
265                    let read_result = Command::new("op")
266                        .args(["read", &config.reference])
267                        .output()
268                        .await;
269
270                    match read_result {
271                        Ok(read_output) if read_output.status.success() => {
272                            *state = CliAuthState::Authenticated;
273                            let secret = String::from_utf8_lossy(&read_output.stdout)
274                                .trim()
275                                .to_string();
276                            return Ok(Some(secret));
277                        }
278                        Ok(read_output) => {
279                            let read_stderr = String::from_utf8_lossy(&read_output.stderr)
280                                .trim()
281                                .to_string();
282                            let read_details = if read_stderr.is_empty() {
283                                "no error output from 1Password CLI".to_string()
284                            } else {
285                                read_stderr
286                            };
287                            let message = format!(
288                                "1Password CLI authentication check failed (`op whoami`) and \
289                                bootstrap secret read failed. Run `op signin` and retry. \
290                                Details: {read_details}"
291                            );
292                            *state = CliAuthState::Failed(message.clone());
293                            return Err(SecretError::ResolutionFailed {
294                                name: name.to_string(),
295                                message,
296                            });
297                        }
298                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
299                            let message = "1Password CLI not found (`op` command unavailable). \
300                                Install the 1Password CLI and retry."
301                                .to_string();
302                            *state = CliAuthState::Failed(message.clone());
303                            return Err(SecretError::ResolutionFailed {
304                                name: name.to_string(),
305                                message,
306                            });
307                        }
308                        Err(e) => {
309                            let message = format!(
310                                "Failed to execute 1Password bootstrap secret read (`op read`): {e}. \
311                                Run `op signin` and retry."
312                            );
313                            *state = CliAuthState::Failed(message.clone());
314                            return Err(SecretError::ResolutionFailed {
315                                name: name.to_string(),
316                                message,
317                            });
318                        }
319                    }
320                }
321
322                let message = format!(
323                    "1Password CLI authentication check failed (`op whoami`). \
324                    Run `op signin` and retry. Details: {details}"
325                );
326                *state = CliAuthState::Failed(message.clone());
327                Err(SecretError::ResolutionFailed {
328                    name: name.to_string(),
329                    message,
330                })
331            }
332            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
333                let message = "1Password CLI not found (`op` command unavailable). \
334                    Install the 1Password CLI and retry."
335                    .to_string();
336                *state = CliAuthState::Failed(message.clone());
337                Err(SecretError::ResolutionFailed {
338                    name: name.to_string(),
339                    message,
340                })
341            }
342            Err(e) => {
343                let message = format!(
344                    "Failed to execute 1Password CLI authentication check (`op whoami`): {e}. \
345                    Run `op signin` and retry."
346                );
347                *state = CliAuthState::Failed(message.clone());
348                Err(SecretError::ResolutionFailed {
349                    name: name.to_string(),
350                    message,
351                })
352            }
353        }
354    }
355
356    /// Resolve a secret - tries HTTP first if available, falls back to CLI
357    async fn resolve_with_config(
358        &self,
359        name: &str,
360        config: &OnePasswordConfig,
361    ) -> Result<String, SecretError> {
362        // Try HTTP mode if available
363        if self.client_id.is_some() {
364            return self.resolve_http(name, config);
365        }
366
367        // Fallback to CLI
368        if let Some(secret) = self.ensure_cli_authenticated(name, config).await? {
369            return Ok(secret);
370        }
371        self.resolve_cli(name, config).await
372    }
373
374    /// Resolve multiple secrets using Secrets.ResolveAll (HTTP mode)
375    fn resolve_batch_http(
376        &self,
377        secrets: &HashMap<String, SecretSpec>,
378    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
379        let client_id = self
380            .client_id
381            .ok_or_else(|| SecretError::ResolutionFailed {
382                name: "batch".to_string(),
383                message: "HTTP client not initialized".to_string(),
384            })?;
385
386        let core_mutex = SharedCore::get_or_init()?;
387        let mut guard = core_mutex
388            .lock()
389            .map_err(|_| SecretError::ResolutionFailed {
390                name: "batch".to_string(),
391                message: "Failed to acquire shared core lock".to_string(),
392            })?;
393
394        let core = guard
395            .as_mut()
396            .ok_or_else(|| SecretError::ResolutionFailed {
397                name: "batch".to_string(),
398                message: "SharedCore not initialized".to_string(),
399            })?;
400
401        // Build list of references and track mapping back to names
402        let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
403        let mut references: Vec<String> = Vec::new();
404
405        for (name, spec) in secrets {
406            let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
407                .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
408
409            ref_to_names
410                .entry(config.reference.clone())
411                .or_default()
412                .push(name.clone());
413
414            if !references.contains(&config.reference) {
415                references.push(config.reference);
416            }
417        }
418
419        // Invoke SecretsResolveAll with array of references
420        let mut params = serde_json::Map::new();
421        params.insert(
422            "secret_references".to_string(),
423            serde_json::Value::Array(
424                references
425                    .iter()
426                    .map(|r| serde_json::Value::String(r.clone()))
427                    .collect(),
428            ),
429        );
430
431        // Use first reference as context for top-level errors
432        let context = references.first().map_or("batch", String::as_str);
433        let result = core.invoke(client_id, "SecretsResolveAll", &params, context)?;
434
435        // Parse the response
436        let response: serde_json::Value =
437            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
438                name: "batch".to_string(),
439                message: format!("Failed to parse ResolveAll response: {e}"),
440            })?;
441
442        // Extract individual responses
443        let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
444            SecretError::ResolutionFailed {
445                name: "batch".to_string(),
446                message: "No individualResponses in response".to_string(),
447            }
448        })?;
449
450        // Map responses back to original names
451        let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
452
453        for (i, resp) in individual_responses.iter().enumerate() {
454            let reference = references
455                .get(i)
456                .ok_or_else(|| SecretError::ResolutionFailed {
457                    name: "batch".to_string(),
458                    message: "Response index out of bounds".to_string(),
459                })?;
460
461            // Check for errors - fail immediately with the specific secret reference
462            if let Some(error) = resp.get("error")
463                && !error.is_null()
464            {
465                let error_type = error["type"].as_str().unwrap_or("Unknown");
466                let error_msg = error["message"].as_str().unwrap_or("Unknown error");
467                return Err(SecretError::ResolutionFailed {
468                    name: reference.clone(),
469                    message: format!("1Password error ({error_type}): {error_msg}"),
470                });
471            }
472
473            // Extract secret value
474            let secret = resp["content"]["secret"]
475                .as_str()
476                .or_else(|| resp["result"].as_str())
477                .ok_or_else(|| SecretError::ResolutionFailed {
478                    name: reference.clone(),
479                    message: "No secret value in response".to_string(),
480                })?;
481
482            // Map to all names that use this reference
483            if let Some(names) = ref_to_names.get(reference) {
484                for name in names {
485                    resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
486                }
487            }
488        }
489
490        Ok(resolved)
491    }
492
493    /// Resolve multiple secrets using CLI (fallback, concurrent)
494    async fn resolve_batch_cli(
495        &self,
496        secrets: &HashMap<String, SecretSpec>,
497    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
498        use futures::future::try_join_all;
499
500        let futures: Vec<_> = secrets
501            .iter()
502            .map(|(name, spec)| {
503                let name = name.clone();
504                let spec = spec.clone();
505                async move {
506                    let value = self.resolve(&name, &spec).await?;
507                    Ok::<_, SecretError>((name, SecureSecret::new(value)))
508                }
509            })
510            .collect();
511
512        try_join_all(futures).await.map(|v| v.into_iter().collect())
513    }
514}
515
516impl Drop for OnePasswordResolver {
517    fn drop(&mut self) {
518        if let Some(client_id) = self.client_id
519            && let Ok(core_mutex) = SharedCore::get_or_init()
520            && let Ok(mut guard) = core_mutex.lock()
521            && let Some(core) = guard.as_mut()
522        {
523            core.release_client(client_id);
524        }
525    }
526}
527
528#[async_trait]
529impl SecretResolver for OnePasswordResolver {
530    fn provider_name(&self) -> &'static str {
531        "onepassword"
532    }
533
534    fn supports_native_batch(&self) -> bool {
535        // 1Password SDK supports Secrets.ResolveAll
536        true
537    }
538
539    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
540        // Try to parse source as JSON OnePasswordConfig
541        if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
542            return self.resolve_with_config(name, &config).await;
543        }
544
545        // Fallback: treat source as a simple reference string
546        let config = OnePasswordConfig::new(spec.source.clone());
547        self.resolve_with_config(name, &config).await
548    }
549
550    async fn resolve_batch(
551        &self,
552        secrets: &HashMap<String, SecretSpec>,
553    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
554        if secrets.is_empty() {
555            return Ok(HashMap::new());
556        }
557
558        // Use Secrets.ResolveAll if HTTP mode is available
559        if self.client_id.is_some() {
560            return self.resolve_batch_http(secrets);
561        }
562
563        // Fallback to concurrent CLI calls
564        self.resolve_batch_cli(secrets).await
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    #[cfg(unix)]
572    use std::os::unix::fs::PermissionsExt;
573    #[cfg(unix)]
574    use std::{fs, path::Path};
575
576    #[cfg(unix)]
577    fn write_fake_op_shim(dir: &Path) -> std::path::PathBuf {
578        let op_path = dir.join("op");
579        let script = r#"#!/bin/sh
580cmd="$1"
581shift
582
583case "$cmd" in
584  whoami)
585    printf "whoami\n" >> "$OP_TEST_LOG"
586    if [ "x$OP_TEST_FAIL_WHOAMI" = "x1" ]; then
587      printf "not signed in\n" >&2
588      exit 1
589    fi
590    printf "test-user@example.com\n"
591    exit 0
592    ;;
593  read)
594    printf "read:%s\n" "$1" >> "$OP_TEST_LOG"
595    if [ "x$OP_TEST_FAIL_READ" = "x1" ]; then
596      printf "read failed\n" >&2
597      exit 1
598    fi
599    printf "secret-for-%s\n" "$1"
600    exit 0
601    ;;
602  *)
603    printf "unsupported op command: %s\n" "$cmd" >&2
604    exit 2
605    ;;
606esac
607"#;
608
609        fs::write(&op_path, script).unwrap();
610        let mut perms = fs::metadata(&op_path).unwrap().permissions();
611        perms.set_mode(0o755);
612        fs::set_permissions(&op_path, perms).unwrap();
613        op_path
614    }
615
616    #[cfg(unix)]
617    fn prepend_path(dir: &Path) -> String {
618        let mut parts = vec![dir.to_path_buf()];
619        if let Some(current) = std::env::var_os("PATH") {
620            parts.extend(std::env::split_paths(&current));
621        }
622        std::env::join_paths(parts)
623            .unwrap()
624            .to_string_lossy()
625            .into_owned()
626    }
627
628    #[cfg(unix)]
629    fn read_log_lines(path: &Path) -> Vec<String> {
630        let content = fs::read_to_string(path).unwrap_or_default();
631        content
632            .lines()
633            .map(str::trim)
634            .filter(|line| !line.is_empty())
635            .map(str::to_string)
636            .collect()
637    }
638
639    #[test]
640    fn test_onepassword_config_serialization() {
641        let config = OnePasswordConfig {
642            reference: "op://vault/item/password".to_string(),
643        };
644
645        let json = serde_json::to_string(&config).unwrap();
646        assert!(json.contains("\"ref\""));
647
648        let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
649        assert_eq!(config, parsed);
650    }
651
652    #[test]
653    fn test_simple_config() {
654        let config = OnePasswordConfig::new("op://Personal/GitHub/token");
655        assert_eq!(config.reference, "op://Personal/GitHub/token");
656    }
657
658    #[test]
659    fn test_config_new_from_string() {
660        let config = OnePasswordConfig::new(String::from("op://vault/item/field"));
661        assert_eq!(config.reference, "op://vault/item/field");
662    }
663
664    #[test]
665    fn test_config_new_from_str_slice() {
666        let ref_str = "op://vault/item/field";
667        let config = OnePasswordConfig::new(ref_str);
668        assert_eq!(config.reference, ref_str);
669    }
670
671    #[test]
672    fn test_config_equality() {
673        let config1 = OnePasswordConfig::new("op://vault/item/field");
674        let config2 = OnePasswordConfig::new("op://vault/item/field");
675        let config3 = OnePasswordConfig::new("op://other/item/field");
676
677        assert_eq!(config1, config2);
678        assert_ne!(config1, config3);
679    }
680
681    #[test]
682    fn test_config_clone() {
683        let config = OnePasswordConfig::new("op://vault/item/field");
684        let cloned = config.clone();
685        assert_eq!(config, cloned);
686    }
687
688    #[test]
689    fn test_config_debug() {
690        let config = OnePasswordConfig::new("op://vault/item/field");
691        let debug = format!("{config:?}");
692        assert!(debug.contains("OnePasswordConfig"));
693        assert!(debug.contains("op://vault/item/field"));
694    }
695
696    #[test]
697    fn test_config_deserialization_with_ref_key() {
698        let json = r#"{"ref": "op://vault/item/field"}"#;
699        let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
700        assert_eq!(config.reference, "op://vault/item/field");
701    }
702
703    #[test]
704    fn test_config_deserialization_camel_case() {
705        // Since serde uses camelCase, the field is "ref"
706        let json = r#"{"ref": "op://example/test/password"}"#;
707        let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
708        assert_eq!(config.reference, "op://example/test/password");
709    }
710
711    #[test]
712    fn test_config_deserialization_missing_ref() {
713        let json = r"{}";
714        let result = serde_json::from_str::<OnePasswordConfig>(json);
715        assert!(result.is_err());
716    }
717
718    #[test]
719    fn test_config_with_special_characters() {
720        let config = OnePasswordConfig::new("op://My Vault/My Item 2024/api-key_v1");
721        assert!(config.reference.contains("My Vault"));
722        assert!(config.reference.contains("api-key_v1"));
723    }
724
725    #[test]
726    fn test_http_mode_available_without_env() {
727        // Without OP_SERVICE_ACCOUNT_TOKEN, HTTP mode should not be available
728        // (unless already set in environment)
729        let result = OnePasswordResolver::http_mode_available();
730        // Just verify it returns a boolean without panicking
731        let _ = result;
732    }
733
734    #[test]
735    fn test_resolver_provider_name() {
736        // Create a resolver in CLI mode (without WASM)
737        // If WASM is not available and token is not set, this should work
738        if (!wasm::onepassword_wasm_available()
739            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
740            && let Ok(resolver) = OnePasswordResolver::new()
741        {
742            assert_eq!(resolver.provider_name(), "onepassword");
743        }
744    }
745
746    #[test]
747    fn test_resolver_supports_native_batch() {
748        if (!wasm::onepassword_wasm_available()
749            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
750            && let Ok(resolver) = OnePasswordResolver::new()
751        {
752            assert!(resolver.supports_native_batch());
753        }
754    }
755
756    #[test]
757    fn test_resolver_can_use_http_false_without_client() {
758        // A resolver without client_id should return false for can_use_http
759        if (!wasm::onepassword_wasm_available()
760            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
761            && let Ok(resolver) = OnePasswordResolver::new()
762        {
763            assert!(!resolver.can_use_http());
764        }
765    }
766
767    #[test]
768    fn test_resolver_debug_output() {
769        if (!wasm::onepassword_wasm_available()
770            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
771            && let Ok(resolver) = OnePasswordResolver::new()
772        {
773            let debug = format!("{resolver:?}");
774            assert!(debug.contains("OnePasswordResolver"));
775            // Should show mode as cli when no WASM client
776            assert!(debug.contains("cli") || debug.contains("http"));
777        }
778    }
779
780    #[tokio::test]
781    async fn test_resolve_batch_empty() {
782        if (!wasm::onepassword_wasm_available()
783            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
784            && let Ok(resolver) = OnePasswordResolver::new()
785        {
786            let empty: HashMap<String, SecretSpec> = HashMap::new();
787            let result = resolver.resolve_batch(&empty).await;
788            assert!(result.is_ok());
789            assert!(result.unwrap().is_empty());
790        }
791    }
792
793    #[test]
794    fn test_config_roundtrip_serialization() {
795        let original = OnePasswordConfig::new("op://vault/item/field");
796        let json = serde_json::to_string(&original).unwrap();
797        let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
798        assert_eq!(original, parsed);
799    }
800
801    #[test]
802    fn test_config_empty_reference() {
803        // Empty reference should be allowed at config level
804        let config = OnePasswordConfig::new("");
805        assert_eq!(config.reference, "");
806    }
807
808    #[test]
809    fn test_config_unicode_reference() {
810        let config = OnePasswordConfig::new("op://vault/项目/密码");
811        assert_eq!(config.reference, "op://vault/项目/密码");
812    }
813
814    #[cfg(unix)]
815    #[tokio::test]
816    async fn test_cli_preflight_runs_once_for_parallel_batch_reads() {
817        let temp = tempfile::tempdir().unwrap();
818        write_fake_op_shim(temp.path());
819        let log_path = temp.path().join("op.log");
820        let path = prepend_path(temp.path());
821        let log_path_str = log_path.to_string_lossy().into_owned();
822
823        temp_env::async_with_vars(
824            [
825                ("PATH", Some(path.as_str())),
826                ("OP_TEST_LOG", Some(log_path_str.as_str())),
827                ("OP_SERVICE_ACCOUNT_TOKEN", None),
828            ],
829            async {
830                let resolver = OnePasswordResolver::new().unwrap();
831                let secrets = HashMap::from([
832                    (
833                        "API_KEY".to_string(),
834                        SecretSpec::new("op://vault/service/api_key"),
835                    ),
836                    (
837                        "DB_PASSWORD".to_string(),
838                        SecretSpec::new("op://vault/service/db_password"),
839                    ),
840                    (
841                        "JWT_SECRET".to_string(),
842                        SecretSpec::new("op://vault/service/jwt_secret"),
843                    ),
844                ]);
845
846                let secret_values = resolver.resolve_batch(&secrets).await.unwrap();
847                assert_eq!(secret_values.len(), 3);
848            },
849        )
850        .await;
851
852        let lines = read_log_lines(&log_path);
853        assert_eq!(
854            lines.iter().filter(|line| *line == "whoami").count(),
855            1,
856            "expected exactly one auth preflight, got log lines: {lines:?}"
857        );
858        assert_eq!(
859            lines
860                .iter()
861                .filter(|line| line.starts_with("read:"))
862                .count(),
863            3,
864            "expected one read per secret, got log lines: {lines:?}"
865        );
866    }
867
868    #[cfg(unix)]
869    #[tokio::test]
870    async fn test_cli_preflight_signed_out_bootstraps_then_runs_parallel_reads() {
871        let temp = tempfile::tempdir().unwrap();
872        write_fake_op_shim(temp.path());
873        let log_path = temp.path().join("op.log");
874        let path = prepend_path(temp.path());
875        let log_path_str = log_path.to_string_lossy().into_owned();
876
877        temp_env::async_with_vars(
878            [
879                ("PATH", Some(path.as_str())),
880                ("OP_TEST_LOG", Some(log_path_str.as_str())),
881                ("OP_TEST_FAIL_WHOAMI", Some("1")),
882                ("OP_SERVICE_ACCOUNT_TOKEN", None),
883            ],
884            async {
885                let resolver = OnePasswordResolver::new().unwrap();
886                let secrets = HashMap::from([
887                    ("A".to_string(), SecretSpec::new("op://vault/item/a")),
888                    ("B".to_string(), SecretSpec::new("op://vault/item/b")),
889                ]);
890
891                let result = resolver.resolve_batch(&secrets).await;
892                assert!(result.is_ok(), "expected bootstrap read to recover auth");
893                assert_eq!(result.unwrap().len(), 2);
894            },
895        )
896        .await;
897
898        let lines = read_log_lines(&log_path);
899        assert_eq!(
900            lines.iter().filter(|line| *line == "whoami").count(),
901            1,
902            "expected single preflight attempt, got log lines: {lines:?}"
903        );
904        assert_eq!(
905            lines
906                .iter()
907                .filter(|line| line.starts_with("read:"))
908                .count(),
909            2,
910            "expected one read per secret after bootstrap auth, got log lines: {lines:?}"
911        );
912    }
913
914    #[cfg(unix)]
915    #[tokio::test]
916    async fn test_cli_preflight_bootstrap_read_failure_fails_fast() {
917        let temp = tempfile::tempdir().unwrap();
918        write_fake_op_shim(temp.path());
919        let log_path = temp.path().join("op.log");
920        let path = prepend_path(temp.path());
921        let log_path_str = log_path.to_string_lossy().into_owned();
922
923        temp_env::async_with_vars(
924            [
925                ("PATH", Some(path.as_str())),
926                ("OP_TEST_LOG", Some(log_path_str.as_str())),
927                ("OP_TEST_FAIL_WHOAMI", Some("1")),
928                ("OP_TEST_FAIL_READ", Some("1")),
929                ("OP_SERVICE_ACCOUNT_TOKEN", None),
930            ],
931            async {
932                let resolver = OnePasswordResolver::new().unwrap();
933                let secrets = HashMap::from([
934                    ("A".to_string(), SecretSpec::new("op://vault/item/a")),
935                    ("B".to_string(), SecretSpec::new("op://vault/item/b")),
936                ]);
937
938                let result = resolver.resolve_batch(&secrets).await;
939                assert!(result.is_err());
940                let err = result.unwrap_err().to_string();
941                assert!(
942                    err.contains("bootstrap secret read"),
943                    "unexpected error: {err}"
944                );
945                assert!(err.contains("op signin"), "unexpected error: {err}");
946            },
947        )
948        .await;
949
950        let lines = read_log_lines(&log_path);
951        assert_eq!(
952            lines.iter().filter(|line| *line == "whoami").count(),
953            1,
954            "expected single preflight attempt, got log lines: {lines:?}"
955        );
956        assert_eq!(
957            lines
958                .iter()
959                .filter(|line| line.starts_with("read:"))
960                .count(),
961            1,
962            "expected a single bootstrap read attempt before fail-fast, got log lines: {lines:?}"
963        );
964    }
965
966    #[cfg(unix)]
967    #[tokio::test]
968    async fn test_cli_preflight_missing_op_reports_clear_error() {
969        let empty_dir = tempfile::tempdir().unwrap();
970        let path = empty_dir.path().to_string_lossy().into_owned();
971
972        temp_env::async_with_vars(
973            [
974                ("PATH", Some(path.as_str())),
975                ("OP_SERVICE_ACCOUNT_TOKEN", None),
976            ],
977            async {
978                let resolver = OnePasswordResolver::new().unwrap();
979                let spec = SecretSpec::new("op://vault/item/password");
980
981                let result = resolver.resolve("missing-op", &spec).await;
982                assert!(result.is_err());
983                let err = result.unwrap_err().to_string();
984                assert!(err.contains("1Password CLI"), "unexpected error: {err}");
985                assert!(err.contains("not found"), "unexpected error: {err}");
986            },
987        )
988        .await;
989    }
990}