openzeppelin_monitor/models/security/
secret.rs

1//! Secret management module for handling sensitive data securely.
2//!
3//! This module provides types and utilities for managing secrets in a secure manner,
4//! with automatic memory zeroization and support for multiple secret sources.
5//!
6//! # Features
7//!
8//! - Secure memory handling with automatic zeroization
9//! - Multiple secret sources (plain text, environment variables, Hashicorp Cloud Vault, etc.)
10//! - Type-safe secret resolution
11//! - Serde support for configuration files
12
13use oz_keystore::HashicorpCloudClient;
14use serde::{Deserialize, Serialize};
15use std::{env, fmt, sync::Arc};
16use tokio::sync::OnceCell;
17use zeroize::{Zeroize, ZeroizeOnDrop};
18
19use crate::{
20	impl_case_insensitive_enum,
21	models::security::{
22		error::{SecurityError, SecurityResult},
23		get_env_var,
24	},
25};
26
27/// Trait for vault clients that can retrieve secrets
28#[async_trait::async_trait]
29pub trait VaultClient: Send + Sync {
30	async fn get_secret(&self, name: &str) -> SecurityResult<SecretString>;
31}
32
33/// Cloud Vault client implementation
34#[derive(Clone)]
35pub struct CloudVaultClient {
36	client: Arc<HashicorpCloudClient>,
37}
38
39impl CloudVaultClient {
40	/// Creates a new CloudVaultClient from environment variables
41	pub fn from_env() -> SecurityResult<Self> {
42		let client_id = get_env_var("HCP_CLIENT_ID")?;
43		let client_secret = get_env_var("HCP_CLIENT_SECRET")?;
44		let org_id = get_env_var("HCP_ORG_ID")?;
45		let project_id = get_env_var("HCP_PROJECT_ID")?;
46		let app_name = get_env_var("HCP_APP_NAME")?;
47		let client =
48			HashicorpCloudClient::new(client_id, client_secret, org_id, project_id, app_name);
49		Ok(Self {
50			client: Arc::new(client),
51		})
52	}
53}
54
55#[async_trait::async_trait]
56impl VaultClient for CloudVaultClient {
57	async fn get_secret(&self, name: &str) -> SecurityResult<SecretString> {
58		let secret = self.client.get_secret(name).await.map_err(|e| {
59			SecurityError::network_error(
60				"Failed to get secret from Hashicorp Cloud Vault",
61				Some(e.into()),
62				None,
63			)
64		})?;
65		Ok(SecretString::new(secret.secret.static_version.value))
66	}
67}
68
69/// Enum representing different vault types
70#[derive(Clone)]
71pub enum VaultType {
72	Cloud(CloudVaultClient),
73}
74
75impl VaultType {
76	/// Creates a new VaultType from environment variables
77	pub fn from_env() -> SecurityResult<Self> {
78		// Default to cloud vault for now
79		Ok(Self::Cloud(CloudVaultClient::from_env()?))
80	}
81}
82
83#[async_trait::async_trait]
84impl VaultClient for VaultType {
85	async fn get_secret(&self, name: &str) -> SecurityResult<SecretString> {
86		match self {
87			Self::Cloud(client) => client.get_secret(name).await,
88		}
89	}
90}
91
92// Global vault client instance
93static VAULT_CLIENT: OnceCell<VaultType> = OnceCell::const_new();
94
95/// Gets the global vault client instance, initializing it if necessary
96pub async fn get_vault_client() -> SecurityResult<&'static VaultType> {
97	VAULT_CLIENT
98		.get_or_try_init(|| async { VaultType::from_env() })
99		.await
100		.map_err(|e| {
101			Box::new(SecurityError::parse_error(
102				"Failed to get vault client",
103				Some(e.into()),
104				None,
105			))
106		})
107}
108
109/// A type that represents a secret value that can be sourced from different places
110/// and ensures proper zeroization of sensitive data.
111///
112/// This enum provides different ways to store and retrieve secrets:
113/// - `Plain`: Direct secret value (wrapped in `SecretString` for secure memory handling)
114/// - `Environment`: Environment variable reference
115/// - `HashicorpCloudVault`: Hashicorp Cloud Vault reference
116///
117/// All variants implement `ZeroizeOnDrop` to ensure secure memory cleanup.
118#[derive(Debug, Clone, Serialize, ZeroizeOnDrop)]
119#[serde(tag = "type", content = "value")]
120#[serde(deny_unknown_fields)]
121pub enum SecretValue {
122	/// A plain text secret value
123	Plain(SecretString),
124	/// A secret stored in an environment variable
125	Environment(String),
126	/// A secret stored in Hashicorp Cloud Vault
127	HashicorpCloudVault(String),
128}
129
130impl_case_insensitive_enum!(SecretValue, {
131	"plain" => Plain,
132	"environment" => Environment,
133	"hashicorpcloudvault" => HashicorpCloudVault,
134});
135
136impl PartialEq for SecretValue {
137	fn eq(&self, other: &Self) -> bool {
138		match (self, other) {
139			(Self::Plain(l0), Self::Plain(r0)) => l0.as_str() == r0.as_str(),
140			(Self::Environment(l0), Self::Environment(r0)) => l0 == r0,
141			(Self::HashicorpCloudVault(l0), Self::HashicorpCloudVault(r0)) => l0 == r0,
142			_ => false,
143		}
144	}
145}
146
147/// A string type that automatically zeroizes its contents when dropped.
148///
149/// This type ensures that sensitive data like passwords and API keys are securely
150/// erased from memory as soon as they're no longer needed. It implements both
151/// `Zeroize` and `ZeroizeOnDrop` to guarantee secure memory cleanup.
152///
153/// # Security
154///
155/// The underlying string is automatically zeroized when:
156/// - The value is dropped
157/// - `zeroize()` is called explicitly
158/// - The value is moved
159#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
160pub struct SecretString(String);
161
162impl PartialEq for SecretString {
163	fn eq(&self, other: &Self) -> bool {
164		self.0 == other.0
165	}
166}
167
168impl SecretValue {
169	/// Resolves the secret value based on its type.
170	///
171	/// This method retrieves the actual secret value from its source:
172	/// - For `Plain`, returns the wrapped `SecretString`
173	/// - For `Environment`, reads the environment variable
174	/// - For `HashicorpCloudVault`, fetches the secret from the vault
175	///
176	/// # Errors
177	///
178	/// Returns a `SecurityError` if:
179	/// - Environment variable is not set
180	/// - Vault access fails
181	/// - Any other security-related error occurs
182	pub async fn resolve(&self) -> SecurityResult<SecretString> {
183		match self {
184			SecretValue::Plain(secret) => Ok(secret.clone()),
185			SecretValue::Environment(env_var) => {
186				env::var(env_var).map(SecretString::new).map_err(|e| {
187					Box::new(SecurityError::parse_error(
188						format!("Failed to get environment variable {}", env_var),
189						Some(e.into()),
190						None,
191					))
192				})
193			}
194			SecretValue::HashicorpCloudVault(name) => {
195				let client = get_vault_client().await?;
196				client.get_secret(name).await.map_err(|e| {
197					Box::new(SecurityError::parse_error(
198						format!("Failed to get secret from Hashicorp Cloud Vault {}", name),
199						Some(e.into()),
200						None,
201					))
202				})
203			}
204		}
205	}
206
207	/// Checks if the secret value starts with a given prefix
208	pub fn starts_with(&self, prefix: &str) -> bool {
209		match self {
210			SecretValue::Plain(secret) => secret.as_str().starts_with(prefix),
211			SecretValue::Environment(env_var) => env_var.starts_with(prefix),
212			SecretValue::HashicorpCloudVault(name) => name.starts_with(prefix),
213		}
214	}
215
216	/// Checks if the secret value is empty
217	pub fn is_empty(&self) -> bool {
218		match self {
219			SecretValue::Plain(secret) => secret.as_str().is_empty(),
220			SecretValue::Environment(env_var) => env_var.is_empty(),
221			SecretValue::HashicorpCloudVault(name) => name.is_empty(),
222		}
223	}
224
225	/// Trims the secret value
226	pub fn trim(&self) -> &str {
227		match self {
228			SecretValue::Plain(secret) => secret.as_str().trim(),
229			SecretValue::Environment(env_var) => env_var.trim(),
230			SecretValue::HashicorpCloudVault(name) => name.trim(),
231		}
232	}
233
234	/// Returns the secret value as a string
235	pub fn as_str(&self) -> &str {
236		match self {
237			SecretValue::Plain(secret) => secret.as_str(),
238			SecretValue::Environment(env_var) => env_var,
239			SecretValue::HashicorpCloudVault(name) => name,
240		}
241	}
242}
243
244impl Zeroize for SecretValue {
245	/// Securely zeroizes the secret value.
246	///
247	/// This implementation ensures that all sensitive data is properly cleared:
248	/// - For `Plain`, zeroizes the underlying `SecretString`
249	/// - For `Environment`, clears the environment variable name
250	/// - For `HashicorpCloudVault`, clears the secret name
251	fn zeroize(&mut self) {
252		match self {
253			SecretValue::Plain(secret) => secret.zeroize(),
254			SecretValue::Environment(env_var) => {
255				// Clear the environment variable name
256				env_var.clear();
257			}
258			SecretValue::HashicorpCloudVault(name) => {
259				name.clear();
260			}
261		}
262	}
263}
264
265impl SecretString {
266	/// Creates a new `SecretString` with the given value.
267	///
268	/// The value will be automatically zeroized when the `SecretString` is dropped.
269	pub fn new(value: String) -> Self {
270		Self(value)
271	}
272
273	/// Gets a reference to the underlying string.
274	///
275	/// # Security Note
276	///
277	/// Be careful with this method as it exposes the secret value.
278	/// The reference should be used immediately and not stored.
279	pub fn as_str(&self) -> &str {
280		&self.0
281	}
282}
283
284impl From<String> for SecretString {
285	fn from(value: String) -> Self {
286		Self::new(value)
287	}
288}
289
290impl AsRef<str> for SecretString {
291	fn as_ref(&self) -> &str {
292		self.as_str()
293	}
294}
295
296impl fmt::Display for SecretValue {
297	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298		match self {
299			SecretValue::Plain(secret) => write!(f, "{}", secret.as_str()),
300			SecretValue::Environment(env_var) => write!(f, "{}", env_var),
301			SecretValue::HashicorpCloudVault(name) => write!(f, "{}", name),
302		}
303	}
304}
305
306impl AsRef<str> for SecretValue {
307	fn as_ref(&self) -> &str {
308		match self {
309			SecretValue::Plain(secret) => secret.as_ref(),
310			SecretValue::Environment(env_var) => env_var,
311			SecretValue::HashicorpCloudVault(name) => name,
312		}
313	}
314}
315
316#[cfg(test)]
317mod tests {
318	use super::*;
319	use lazy_static::lazy_static;
320	use std::sync::atomic::{AtomicBool, Ordering};
321	use std::sync::Mutex;
322	use zeroize::Zeroize;
323
324	// Static mutex for environment variable synchronization
325	lazy_static! {
326		static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
327	}
328
329	// Helper function to set up test environment that handles mutex poisoning
330	#[allow(clippy::await_holding_lock)]
331	async fn with_test_env<F, Fut>(f: F)
332	where
333		F: FnOnce() -> Fut,
334		Fut: std::future::Future<Output = ()>,
335	{
336		// Simpler lock acquisition without poisoning recovery
337		let _lock = ENV_MUTEX.lock().unwrap();
338
339		let env_vars = [
340			("HCP_CLIENT_ID", "test-client-id"),
341			("HCP_CLIENT_SECRET", "test-client-secret"),
342			("HCP_ORG_ID", "test-org"),
343			("HCP_PROJECT_ID", "test-project"),
344			("HCP_APP_NAME", "test-app"),
345		];
346
347		// Store original values to restore later
348		let original_values: Vec<_> = env_vars
349			.iter()
350			.map(|(key, _)| (*key, std::env::var(key).ok()))
351			.collect();
352
353		// Set up environment variables
354		for (key, value) in env_vars.iter() {
355			std::env::set_var(key, value);
356		}
357
358		// Run the test
359		f().await;
360
361		// Restore environment variables
362		for (key, value) in original_values {
363			match value {
364				Some(val) => std::env::set_var(key, val),
365				None => std::env::remove_var(key),
366			}
367		}
368	}
369
370	// Generic wrapper type that tracks zeroization
371	struct TrackedSecret<T: Zeroize> {
372		inner: T,
373		was_zeroized: Arc<AtomicBool>,
374	}
375
376	impl<T: Zeroize> TrackedSecret<T> {
377		fn new(value: T, was_zeroized: Arc<AtomicBool>) -> Self {
378			Self {
379				inner: value,
380				was_zeroized,
381			}
382		}
383	}
384
385	impl<T: Zeroize> Zeroize for TrackedSecret<T> {
386		fn zeroize(&mut self) {
387			self.was_zeroized.store(true, Ordering::SeqCst);
388			self.inner.zeroize();
389		}
390	}
391
392	impl<T: Zeroize> Drop for TrackedSecret<T> {
393		fn drop(&mut self) {
394			self.zeroize();
395		}
396	}
397
398	/// Tests that SecretString is zeroized when it goes out of scope
399	#[test]
400	fn test_secret_string_zeroize_on_drop() {
401		let was_zeroized = Arc::new(AtomicBool::new(false));
402		let secret = "sensitive_data".to_string();
403		let secret_string =
404			TrackedSecret::new(SecretString::new(secret.clone()), was_zeroized.clone());
405
406		// Verify initial state
407		assert_eq!(secret_string.inner.as_str(), secret);
408		assert!(!was_zeroized.load(Ordering::SeqCst));
409
410		// Move secret_string into a new scope
411		{
412			let _secret_string = secret_string;
413			// secret_string should still be accessible
414			assert_eq!(_secret_string.inner.as_str(), secret);
415			assert!(!was_zeroized.load(Ordering::SeqCst));
416		}
417
418		// After the scope ends, the value should be zeroized
419		assert!(was_zeroized.load(Ordering::SeqCst));
420	}
421
422	/// Tests that SecretValue is zeroized when it goes out of scope
423	#[test]
424	fn test_secret_value_zeroize_on_drop() {
425		let was_zeroized = Arc::new(AtomicBool::new(false));
426		let secret = "sensitive_data".to_string();
427		let secret_value = TrackedSecret::new(
428			SecretValue::Plain(SecretString::new(secret.clone())),
429			was_zeroized.clone(),
430		);
431
432		// Verify initial state
433		assert_eq!(secret_value.inner.as_str(), secret);
434		assert!(!was_zeroized.load(Ordering::SeqCst));
435
436		// Move secret_value into a new scope
437		{
438			let _secret_value = secret_value;
439			// secret_value should still be accessible
440			assert_eq!(_secret_value.inner.as_str(), secret);
441			assert!(!was_zeroized.load(Ordering::SeqCst));
442		}
443
444		// After the scope ends, the value should be zeroized
445		assert!(was_zeroized.load(Ordering::SeqCst));
446	}
447
448	/// Tests environment variable secret resolution
449	#[tokio::test]
450	async fn test_environment_secret() {
451		const TEST_ENV_VAR: &str = "TEST_SECRET_ENV_VAR";
452		const TEST_SECRET: &str = "test_secret_value";
453
454		env::set_var(TEST_ENV_VAR, TEST_SECRET);
455
456		let secret = SecretValue::Environment(TEST_ENV_VAR.to_string());
457		let resolved = secret.resolve().await.unwrap();
458
459		assert_eq!(resolved.as_str(), TEST_SECRET);
460
461		env::remove_var(TEST_ENV_VAR);
462	}
463
464	/// Tests manual zeroization of SecretString
465	#[test]
466	fn test_secret_string_zeroize() {
467		let secret = "sensitive_data".to_string();
468		let mut secret_string = SecretString::new(secret.clone());
469
470		assert_eq!(secret_string.as_str(), secret);
471
472		// Manually zeroize
473		secret_string.zeroize();
474		assert_eq!(secret_string.as_str(), "");
475	}
476
477	/// Tests zeroization of all SecretValue variants
478	#[test]
479	fn test_secret_value_zeroize() {
480		let mut plain_secret = SecretValue::Plain(SecretString::new("plain_secret".to_string()));
481		let mut env_secret = SecretValue::Environment("ENV_VAR".to_string());
482		let mut cloud_vault_secret = SecretValue::HashicorpCloudVault("secret_name".to_string());
483
484		plain_secret.zeroize();
485		env_secret.zeroize();
486		cloud_vault_secret.zeroize();
487
488		// After zeroize, the values should be cleared
489		if let SecretValue::Plain(ref secret) = plain_secret {
490			assert_eq!(secret.as_str(), "");
491		}
492
493		if let SecretValue::Environment(ref env_var) = env_secret {
494			assert_eq!(env_var, "");
495		}
496		if let SecretValue::HashicorpCloudVault(ref name) = cloud_vault_secret {
497			assert_eq!(name, "");
498		}
499	}
500
501	#[tokio::test]
502	async fn test_cloud_vault_client_from_env_success() {
503		with_test_env(|| async {
504			let result = CloudVaultClient::from_env();
505			assert!(result.is_ok());
506		})
507		.await;
508	}
509
510	#[tokio::test]
511	async fn test_cloud_vault_client_from_env_missing_vars() {
512		with_test_env(|| async {
513			// Test missing HCP_CLIENT_ID
514			std::env::remove_var("HCP_CLIENT_ID");
515			let result = CloudVaultClient::from_env();
516			assert!(result.is_err());
517			assert!(result.err().unwrap().to_string().contains("HCP_CLIENT_ID"));
518		})
519		.await;
520
521		with_test_env(|| async {
522			// Test missing HCP_CLIENT_SECRET
523			std::env::remove_var("HCP_CLIENT_SECRET");
524			let result = CloudVaultClient::from_env();
525			assert!(result.is_err());
526			assert!(result
527				.err()
528				.unwrap()
529				.to_string()
530				.contains("HCP_CLIENT_SECRET"));
531		})
532		.await;
533	}
534
535	#[tokio::test]
536	async fn test_vault_type_from_env() {
537		with_test_env(|| async {
538			let result = VaultType::from_env();
539			assert!(result.is_ok());
540			match result.unwrap() {
541				VaultType::Cloud(_) => (), // Expected
542			}
543		})
544		.await;
545	}
546
547	#[tokio::test]
548	async fn test_get_vault_client() {
549		with_test_env(|| async {
550			// First fail to get the vault client if the environment variables are not set
551			// The order of this test is important since we can only initialise the client once due to
552			// the global state
553			std::env::remove_var("HCP_CLIENT_ID");
554			let result = get_vault_client().await;
555			assert!(result.is_err());
556			assert!(result
557				.err()
558				.unwrap()
559				.to_string()
560				.contains("Failed to get vault client"));
561
562			// Set the environment variable
563			std::env::set_var("HCP_CLIENT_ID", "test-client-id");
564
565			// Then call should initialize the client
566			let result = get_vault_client().await;
567			assert!(result.is_ok());
568			let client = result.unwrap();
569			match client {
570				VaultType::Cloud(_) => (), // Expected
571			}
572
573			// Second call should return the same instance
574			let result2 = get_vault_client().await;
575			assert!(result2.is_ok());
576			assert!(std::ptr::eq(client, result2.unwrap()));
577		})
578		.await;
579	}
580
581	#[tokio::test]
582	async fn test_vault_client_get_secret() {
583		let mut server = mockito::Server::new_async().await;
584		// Mock the token request
585		let token_mock = server
586			.mock("POST", "/oauth2/token")
587			.with_status(200)
588			.with_header("content-type", "application/json")
589			.with_body(
590				r#"{"access_token": "test-token", "token_type": "Bearer", "expires_in": 3600}"#,
591			)
592			.create_async()
593			.await;
594
595		// Mock the secret request
596		let secret_mock = server
597			.mock(
598				"GET",
599				"/secrets/2023-11-28/organizations/test-org/projects/test-project/apps/test-app/secrets/test-secret:open",
600			)
601			.with_status(200)
602			.with_header("content-type", "application/json")
603			.with_body(r#"{"secret": {"static_version": {"value": "test-secret-value"}}}"#)
604			.create_async()
605			.await;
606
607		// Create the HashicorpCloudClient with the custom client
608		let hashicorp_client = HashicorpCloudClient::new(
609			"test-client-id".to_string(),
610			"test-client-secret".to_string(),
611			"test-org".to_string(),
612			"test-project".to_string(),
613			"test-app".to_string(),
614		)
615		.with_api_base_url(server.url())
616		.with_auth_base_url(server.url());
617
618		let vault_client = CloudVaultClient {
619			client: Arc::new(hashicorp_client),
620		};
621
622		// Get the secret
623		let result = vault_client.get_secret("test-secret").await;
624
625		// Verify the mocks were called
626		token_mock.assert_async().await;
627		secret_mock.assert_async().await;
628
629		// Verify the result
630		assert!(result.is_ok());
631		assert_eq!(result.unwrap().as_str(), "test-secret-value");
632	}
633
634	#[tokio::test]
635	async fn test_vault_client_get_secret_error() {
636		with_test_env(|| async {
637			// Create a mock server that will return an error
638			let mut server = mockito::Server::new_async().await;
639			let token_mock = server
640				.mock("POST", "/oauth2/token")
641				.with_status(500)
642				.with_header("content-type", "application/json")
643				.with_body(r#"{"error": "internal server error"}"#)
644				.create_async()
645				.await;
646
647			// Create the HashicorpCloudClient with the custom client
648			let hashicorp_client = HashicorpCloudClient::new(
649				"test-client-id".to_string(),
650				"test-client-secret".to_string(),
651				"test-org".to_string(),
652				"test-project".to_string(),
653				"test-app".to_string(),
654			)
655			.with_api_base_url(server.url())
656			.with_auth_base_url(server.url());
657
658			let vault_client = CloudVaultClient {
659				client: Arc::new(hashicorp_client),
660			};
661
662			let result = vault_client.get_secret("test-secret").await;
663
664			// Verify the mock was called
665			token_mock.assert_async().await;
666
667			// Verify the error
668			assert!(result.is_err());
669			assert!(result
670				.err()
671				.unwrap()
672				.to_string()
673				.contains("Failed to get secret from Hashicorp Cloud Vault"));
674		})
675		.await;
676	}
677
678	#[tokio::test]
679	async fn test_vault_type_clone() {
680		with_test_env(|| async {
681			let vault_type = VaultType::from_env().unwrap();
682			let cloned = vault_type.clone();
683
684			match (vault_type, cloned) {
685				(VaultType::Cloud(_), VaultType::Cloud(_)) => (), // Expected
686			}
687		})
688		.await;
689	}
690
691	#[test]
692	fn test_cloud_vault_client_new_wraps_arc() {
693		let dummy = HashicorpCloudClient::new(
694			"id".to_string(),
695			"secret".to_string(),
696			"org".to_string(),
697			"proj".to_string(),
698			"app".to_string(),
699		);
700		let client = CloudVaultClient {
701			client: Arc::new(dummy),
702		};
703		// Arc should be used internally (cannot test Arc directly, but can check type)
704		assert!(Arc::strong_count(&client.client) >= 1);
705	}
706
707	#[tokio::test]
708	async fn test_cloud_vault_client_from_env_missing_org_id() {
709		with_test_env(|| async {
710			std::env::remove_var("HCP_ORG_ID");
711			let result = CloudVaultClient::from_env();
712			assert!(result.is_err());
713			assert!(result.err().unwrap().to_string().contains("HCP_ORG_ID"));
714		})
715		.await;
716	}
717
718	#[tokio::test]
719	async fn test_cloud_vault_client_from_env_missing_project_id() {
720		with_test_env(|| async {
721			std::env::remove_var("HCP_PROJECT_ID");
722			let result = CloudVaultClient::from_env();
723			assert!(result.is_err());
724			assert!(result.err().unwrap().to_string().contains("HCP_PROJECT_ID"));
725		})
726		.await;
727	}
728
729	#[tokio::test]
730	async fn test_cloud_vault_client_from_env_missing_app_name() {
731		with_test_env(|| async {
732			std::env::remove_var("HCP_APP_NAME");
733			let result = CloudVaultClient::from_env();
734			assert!(result.is_err());
735			assert!(result.err().unwrap().to_string().contains("HCP_APP_NAME"));
736		})
737		.await;
738	}
739
740	#[tokio::test]
741	async fn test_cloud_vault_client_from_env_missing_client_id() {
742		with_test_env(|| async {
743			std::env::remove_var("HCP_CLIENT_ID");
744			let result = CloudVaultClient::from_env();
745			assert!(result.is_err());
746			assert!(result.err().unwrap().to_string().contains("HCP_CLIENT_ID"));
747		})
748		.await;
749	}
750
751	#[tokio::test]
752	async fn test_cloud_vault_client_from_env_missing_client_secret() {
753		with_test_env(|| async {
754			std::env::remove_var("HCP_CLIENT_SECRET");
755			let result = CloudVaultClient::from_env();
756			assert!(result.is_err());
757			assert!(result
758				.err()
759				.unwrap()
760				.to_string()
761				.contains("HCP_CLIENT_SECRET"));
762		})
763		.await;
764	}
765
766	#[tokio::test]
767	async fn test_vault_type_get_secret_delegates() {
768		with_test_env(|| async {
769			let vault = VaultType::from_env().unwrap();
770			let result = vault.get_secret("nonexistent").await;
771			assert!(
772				result.is_err(),
773				"Expected error for nonexistent secret, got: {:?}",
774				result
775			);
776		})
777		.await;
778	}
779
780	#[test]
781	fn test_secret_value_partial_eq_false_for_different_variants() {
782		let a = SecretValue::Plain(SecretString::new("a".to_string()));
783		let b = SecretValue::Environment("a".to_string());
784		let c = SecretValue::HashicorpCloudVault("a".to_string());
785		assert_ne!(a, b);
786		assert_ne!(a, c);
787		assert_ne!(b, c);
788	}
789
790	#[test]
791	fn test_secret_string_partial_eq() {
792		let a = SecretString::new("foo".to_string());
793		let b = SecretString::new("foo".to_string());
794		let c = SecretString::new("bar".to_string());
795		assert_eq!(a, b);
796		assert_ne!(a, c);
797	}
798
799	#[tokio::test]
800	async fn test_secret_value_resolve_env_error() {
801		let secret = SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string());
802		let result = secret.resolve().await;
803		assert!(result.is_err());
804		assert!(result
805			.err()
806			.unwrap()
807			.to_string()
808			.contains("Failed to get environment variable"));
809	}
810
811	#[tokio::test]
812	async fn test_secret_value_resolve_hashicorp_cloud_vault_error() {
813		with_test_env(|| async {
814			let secret = SecretValue::HashicorpCloudVault("NON_EXISTENT_VAULT_SECRET".to_string());
815			let result = secret.resolve().await;
816			assert!(result.is_err());
817			assert!(result
818				.err()
819				.unwrap()
820				.to_string()
821				.contains("Failed to get secret from Hashicorp Cloud Vault"));
822		})
823		.await;
824	}
825
826	#[test]
827	fn test_secret_value_starts_with() {
828		let plain = SecretValue::Plain(SecretString::new("PREFIX_value".to_string()));
829		let env = SecretValue::Environment("PREFIX_value".to_string());
830		let vault = SecretValue::HashicorpCloudVault("PREFIX_secret".to_string());
831		assert!(plain.starts_with("PREFIX"));
832		assert!(env.starts_with("PREFIX"));
833		assert!(vault.starts_with("PREFIX"));
834		assert!(!plain.starts_with("NOPE"));
835		assert!(!env.starts_with("NOPE"));
836		assert!(!vault.starts_with("NOPE"));
837	}
838
839	#[test]
840	fn test_secret_value_is_empty() {
841		let plain = SecretValue::Plain(SecretString::new("".to_string()));
842		let env = SecretValue::Environment("".to_string());
843		let vault = SecretValue::HashicorpCloudVault("".to_string());
844		assert!(plain.is_empty());
845		assert!(env.is_empty());
846		assert!(vault.is_empty());
847
848		let plain2 = SecretValue::Plain(SecretString::new("notempty".to_string()));
849		let env2 = SecretValue::Environment("notempty".to_string());
850		let vault2 = SecretValue::HashicorpCloudVault("notempty".to_string());
851		assert!(!plain2.is_empty());
852		assert!(!env2.is_empty());
853		assert!(!vault2.is_empty());
854	}
855
856	#[test]
857	fn test_secret_value_trim() {
858		let plain = SecretValue::Plain(SecretString::new("  plainval  ".to_string()));
859		let env = SecretValue::Environment("  foo  ".to_string());
860		let vault = SecretValue::HashicorpCloudVault("  bar  ".to_string());
861		assert_eq!(plain.trim(), "plainval");
862		assert_eq!(env.trim(), "foo");
863		assert_eq!(vault.trim(), "bar");
864	}
865
866	#[test]
867	fn test_secret_value_as_str() {
868		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
869		let env = SecretValue::Environment("envval".to_string());
870		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
871		assert_eq!(plain.as_str(), "plainval");
872		assert_eq!(env.as_str(), "envval");
873		assert_eq!(vault.as_str(), "vaultval");
874	}
875
876	#[test]
877	fn test_secret_string_from_string() {
878		let s: SecretString = String::from("foo").into();
879		assert_eq!(s.as_str(), "foo");
880	}
881
882	#[test]
883	fn test_secret_value_display() {
884		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
885		let env = SecretValue::Environment("envval".to_string());
886		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
887		assert_eq!(format!("{}", plain), "plainval");
888		assert_eq!(format!("{}", env), "envval");
889		assert_eq!(format!("{}", vault), "vaultval");
890	}
891
892	#[test]
893	fn test_secret_value_as_ref() {
894		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
895		let env = SecretValue::Environment("envval".to_string());
896		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
897		assert_eq!(plain.as_ref(), "plainval");
898		assert_eq!(env.as_ref(), "envval");
899		assert_eq!(vault.as_ref(), "vaultval");
900	}
901
902	#[test]
903	fn test_case_insensitive_deserialization() {
904		// Test with uppercase variant names
905		let uppercase_json = r#"{"type":"PLAIN","value":"test_secret"}"#;
906		let uppercase_result: Result<SecretValue, _> = serde_json::from_str(uppercase_json);
907		assert!(
908			uppercase_result.is_ok(),
909			"Failed to deserialize uppercase variant: {:?}",
910			uppercase_result.err()
911		);
912
913		if let Ok(ref secret_value) = uppercase_result {
914			match secret_value {
915				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
916				_ => panic!("Expected Plain variant"),
917			}
918		}
919
920		// Test with lowercase variant names
921		let lowercase_json = r#"{"type":"plain","value":"test_secret"}"#;
922		let lowercase_result: Result<SecretValue, _> = serde_json::from_str(lowercase_json);
923		assert!(
924			lowercase_result.is_ok(),
925			"Failed to deserialize lowercase variant: {:?}",
926			lowercase_result.err()
927		);
928
929		if let Ok(ref secret_value) = lowercase_result {
930			match secret_value {
931				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
932				_ => panic!("Expected Plain variant"),
933			}
934		}
935
936		// Test with mixed case variant names
937		let mixedcase_json = r#"{"type":"pLaIn","value":"test_secret"}"#;
938		let mixedcase_result: Result<SecretValue, _> = serde_json::from_str(mixedcase_json);
939		assert!(
940			mixedcase_result.is_ok(),
941			"Failed to deserialize mixed case variant: {:?}",
942			mixedcase_result.err()
943		);
944
945		if let Ok(ref secret_value) = mixedcase_result {
946			match secret_value {
947				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
948				_ => panic!("Expected Plain variant"),
949			}
950		}
951
952		// Test environment variant
953		let env_json = r#"{"type":"environment","value":"ENV_VAR"}"#;
954		let env_result: Result<SecretValue, _> = serde_json::from_str(env_json);
955		assert!(env_result.is_ok());
956
957		if let Ok(ref secret_value) = env_result {
958			match secret_value {
959				SecretValue::Environment(env_var) => assert_eq!(env_var, "ENV_VAR"),
960				_ => panic!("Expected Environment variant"),
961			}
962		}
963
964		// Test vault variant
965		let vault_json = r#"{"type":"hashicorpcloudvault","value":"secret_name"}"#;
966		let vault_result: Result<SecretValue, _> = serde_json::from_str(vault_json);
967		assert!(vault_result.is_ok());
968
969		if let Ok(ref secret_value) = vault_result {
970			match secret_value {
971				SecretValue::HashicorpCloudVault(name) => assert_eq!(name, "secret_name"),
972				_ => panic!("Expected HashicorpCloudVault variant"),
973			}
974		}
975	}
976}