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(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(_) => write!(f, "<secret string>"),
300			SecretValue::Environment(env_var) => write!(f, "{}", env_var),
301			SecretValue::HashicorpCloudVault(name) => write!(f, "{}", name),
302		}
303	}
304}
305
306impl fmt::Debug for SecretString {
307	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308		write!(f, "<secret string>")
309	}
310}
311
312impl fmt::Display for SecretString {
313	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314		write!(f, "<secret string>")
315	}
316}
317
318impl AsRef<str> for SecretValue {
319	fn as_ref(&self) -> &str {
320		match self {
321			SecretValue::Plain(secret) => secret.as_ref(),
322			SecretValue::Environment(env_var) => env_var,
323			SecretValue::HashicorpCloudVault(name) => name,
324		}
325	}
326}
327
328#[cfg(test)]
329mod tests {
330	use super::*;
331	use lazy_static::lazy_static;
332	use std::sync::atomic::{AtomicBool, Ordering};
333	use std::sync::Mutex;
334	use zeroize::Zeroize;
335
336	// Static mutex for environment variable synchronization
337	lazy_static! {
338		static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
339	}
340
341	// Helper function to set up test environment that handles mutex poisoning
342	#[allow(clippy::await_holding_lock)]
343	async fn with_test_env<F, Fut>(f: F)
344	where
345		F: FnOnce() -> Fut,
346		Fut: std::future::Future<Output = ()>,
347	{
348		// Simpler lock acquisition without poisoning recovery
349		let _lock = ENV_MUTEX.lock().unwrap();
350
351		let env_vars = [
352			("HCP_CLIENT_ID", "test-client-id"),
353			("HCP_CLIENT_SECRET", "test-client-secret"),
354			("HCP_ORG_ID", "test-org"),
355			("HCP_PROJECT_ID", "test-project"),
356			("HCP_APP_NAME", "test-app"),
357		];
358
359		// Store original values to restore later
360		let original_values: Vec<_> = env_vars
361			.iter()
362			.map(|(key, _)| (*key, std::env::var(key).ok()))
363			.collect();
364
365		// Set up environment variables
366		for (key, value) in env_vars.iter() {
367			std::env::set_var(key, value);
368		}
369
370		// Run the test
371		f().await;
372
373		// Restore environment variables
374		for (key, value) in original_values {
375			match value {
376				Some(val) => std::env::set_var(key, val),
377				None => std::env::remove_var(key),
378			}
379		}
380	}
381
382	// Generic wrapper type that tracks zeroization
383	struct TrackedSecret<T: Zeroize> {
384		inner: T,
385		was_zeroized: Arc<AtomicBool>,
386	}
387
388	impl<T: Zeroize> TrackedSecret<T> {
389		fn new(value: T, was_zeroized: Arc<AtomicBool>) -> Self {
390			Self {
391				inner: value,
392				was_zeroized,
393			}
394		}
395	}
396
397	impl<T: Zeroize> Zeroize for TrackedSecret<T> {
398		fn zeroize(&mut self) {
399			self.was_zeroized.store(true, Ordering::SeqCst);
400			self.inner.zeroize();
401		}
402	}
403
404	impl<T: Zeroize> Drop for TrackedSecret<T> {
405		fn drop(&mut self) {
406			self.zeroize();
407		}
408	}
409
410	/// Tests that SecretString is zeroized when it goes out of scope
411	#[test]
412	fn test_secret_string_zeroize_on_drop() {
413		let was_zeroized = Arc::new(AtomicBool::new(false));
414		let secret = "sensitive_data".to_string();
415		let secret_string =
416			TrackedSecret::new(SecretString::new(secret.clone()), was_zeroized.clone());
417
418		// Verify initial state
419		assert_eq!(secret_string.inner.as_str(), secret);
420		assert!(!was_zeroized.load(Ordering::SeqCst));
421
422		// Move secret_string into a new scope
423		{
424			let _secret_string = secret_string;
425			// secret_string should still be accessible
426			assert_eq!(_secret_string.inner.as_str(), secret);
427			assert!(!was_zeroized.load(Ordering::SeqCst));
428		}
429
430		// After the scope ends, the value should be zeroized
431		assert!(was_zeroized.load(Ordering::SeqCst));
432	}
433
434	/// Tests that SecretValue is zeroized when it goes out of scope
435	#[test]
436	fn test_secret_value_zeroize_on_drop() {
437		let was_zeroized = Arc::new(AtomicBool::new(false));
438		let secret = "sensitive_data".to_string();
439		let secret_value = TrackedSecret::new(
440			SecretValue::Plain(SecretString::new(secret.clone())),
441			was_zeroized.clone(),
442		);
443
444		// Verify initial state
445		assert_eq!(secret_value.inner.as_str(), secret);
446		assert!(!was_zeroized.load(Ordering::SeqCst));
447
448		// Move secret_value into a new scope
449		{
450			let _secret_value = secret_value;
451			// secret_value should still be accessible
452			assert_eq!(_secret_value.inner.as_str(), secret);
453			assert!(!was_zeroized.load(Ordering::SeqCst));
454		}
455
456		// After the scope ends, the value should be zeroized
457		assert!(was_zeroized.load(Ordering::SeqCst));
458	}
459
460	/// Tests environment variable secret resolution
461	#[tokio::test]
462	async fn test_environment_secret() {
463		const TEST_ENV_VAR: &str = "TEST_SECRET_ENV_VAR";
464		const TEST_SECRET: &str = "test_secret_value";
465
466		env::set_var(TEST_ENV_VAR, TEST_SECRET);
467
468		let secret = SecretValue::Environment(TEST_ENV_VAR.to_string());
469		let resolved = secret.resolve().await.unwrap();
470
471		assert_eq!(resolved.as_str(), TEST_SECRET);
472
473		env::remove_var(TEST_ENV_VAR);
474	}
475
476	/// Tests manual zeroization of SecretString
477	#[test]
478	fn test_secret_string_zeroize() {
479		let secret = "sensitive_data".to_string();
480		let mut secret_string = SecretString::new(secret.clone());
481
482		assert_eq!(secret_string.as_str(), secret);
483
484		// Manually zeroize
485		secret_string.zeroize();
486		assert_eq!(secret_string.as_str(), "");
487	}
488
489	/// Tests zeroization of all SecretValue variants
490	#[test]
491	fn test_secret_value_zeroize() {
492		let mut plain_secret = SecretValue::Plain(SecretString::new("plain_secret".to_string()));
493		let mut env_secret = SecretValue::Environment("ENV_VAR".to_string());
494		let mut cloud_vault_secret = SecretValue::HashicorpCloudVault("secret_name".to_string());
495
496		plain_secret.zeroize();
497		env_secret.zeroize();
498		cloud_vault_secret.zeroize();
499
500		// After zeroize, the values should be cleared
501		if let SecretValue::Plain(ref secret) = plain_secret {
502			assert_eq!(secret.as_str(), "");
503		}
504
505		if let SecretValue::Environment(ref env_var) = env_secret {
506			assert_eq!(env_var, "");
507		}
508		if let SecretValue::HashicorpCloudVault(ref name) = cloud_vault_secret {
509			assert_eq!(name, "");
510		}
511	}
512
513	#[tokio::test]
514	async fn test_cloud_vault_client_from_env_success() {
515		with_test_env(|| async {
516			let result = CloudVaultClient::from_env();
517			assert!(result.is_ok());
518		})
519		.await;
520	}
521
522	#[tokio::test]
523	async fn test_cloud_vault_client_from_env_missing_vars() {
524		with_test_env(|| async {
525			// Test missing HCP_CLIENT_ID
526			std::env::remove_var("HCP_CLIENT_ID");
527			let result = CloudVaultClient::from_env();
528			assert!(result.is_err());
529			assert!(result.err().unwrap().to_string().contains("HCP_CLIENT_ID"));
530		})
531		.await;
532
533		with_test_env(|| async {
534			// Test missing HCP_CLIENT_SECRET
535			std::env::remove_var("HCP_CLIENT_SECRET");
536			let result = CloudVaultClient::from_env();
537			assert!(result.is_err());
538			assert!(result
539				.err()
540				.unwrap()
541				.to_string()
542				.contains("HCP_CLIENT_SECRET"));
543		})
544		.await;
545	}
546
547	#[tokio::test]
548	async fn test_vault_type_from_env() {
549		with_test_env(|| async {
550			let result = VaultType::from_env();
551			assert!(result.is_ok());
552			match result.unwrap() {
553				VaultType::Cloud(_) => (), // Expected
554			}
555		})
556		.await;
557	}
558
559	#[tokio::test]
560	async fn test_get_vault_client() {
561		with_test_env(|| async {
562			// First fail to get the vault client if the environment variables are not set
563			// The order of this test is important since we can only initialise the client once due to
564			// the global state
565			std::env::remove_var("HCP_CLIENT_ID");
566			let result = get_vault_client().await;
567			assert!(result.is_err());
568			assert!(result
569				.err()
570				.unwrap()
571				.to_string()
572				.contains("Failed to get vault client"));
573
574			// Set the environment variable
575			std::env::set_var("HCP_CLIENT_ID", "test-client-id");
576
577			// Then call should initialize the client
578			let result = get_vault_client().await;
579			assert!(result.is_ok());
580			let client = result.unwrap();
581			match client {
582				VaultType::Cloud(_) => (), // Expected
583			}
584
585			// Second call should return the same instance
586			let result2 = get_vault_client().await;
587			assert!(result2.is_ok());
588			assert!(std::ptr::eq(client, result2.unwrap()));
589		})
590		.await;
591	}
592
593	#[tokio::test]
594	async fn test_vault_client_get_secret() {
595		let mut server = mockito::Server::new_async().await;
596		// Mock the token request
597		let token_mock = server
598			.mock("POST", "/oauth2/token")
599			.with_status(200)
600			.with_header("content-type", "application/json")
601			.with_body(
602				r#"{"access_token": "test-token", "token_type": "Bearer", "expires_in": 3600}"#,
603			)
604			.create_async()
605			.await;
606
607		// Mock the secret request
608		let secret_mock = server
609			.mock(
610				"GET",
611				"/secrets/2023-11-28/organizations/test-org/projects/test-project/apps/test-app/secrets/test-secret:open",
612			)
613			.with_status(200)
614			.with_header("content-type", "application/json")
615			.with_body(r#"{"secret": {"static_version": {"value": "test-secret-value"}}}"#)
616			.create_async()
617			.await;
618
619		// Create the HashicorpCloudClient with the custom client
620		let hashicorp_client = HashicorpCloudClient::new(
621			"test-client-id".to_string(),
622			"test-client-secret".to_string(),
623			"test-org".to_string(),
624			"test-project".to_string(),
625			"test-app".to_string(),
626		)
627		.with_api_base_url(server.url())
628		.with_auth_base_url(server.url());
629
630		let vault_client = CloudVaultClient {
631			client: Arc::new(hashicorp_client),
632		};
633
634		// Get the secret
635		let result = vault_client.get_secret("test-secret").await;
636
637		// Verify the mocks were called
638		token_mock.assert_async().await;
639		secret_mock.assert_async().await;
640
641		// Verify the result
642		assert!(result.is_ok());
643		assert_eq!(result.unwrap().as_str(), "test-secret-value");
644	}
645
646	#[tokio::test]
647	async fn test_vault_client_get_secret_error() {
648		with_test_env(|| async {
649			// Create a mock server that will return an error
650			let mut server = mockito::Server::new_async().await;
651			let token_mock = server
652				.mock("POST", "/oauth2/token")
653				.with_status(500)
654				.with_header("content-type", "application/json")
655				.with_body(r#"{"error": "internal server error"}"#)
656				.create_async()
657				.await;
658
659			// Create the HashicorpCloudClient with the custom client
660			let hashicorp_client = HashicorpCloudClient::new(
661				"test-client-id".to_string(),
662				"test-client-secret".to_string(),
663				"test-org".to_string(),
664				"test-project".to_string(),
665				"test-app".to_string(),
666			)
667			.with_api_base_url(server.url())
668			.with_auth_base_url(server.url());
669
670			let vault_client = CloudVaultClient {
671				client: Arc::new(hashicorp_client),
672			};
673
674			let result = vault_client.get_secret("test-secret").await;
675
676			// Verify the mock was called
677			token_mock.assert_async().await;
678
679			// Verify the error
680			assert!(result.is_err());
681			assert!(result
682				.err()
683				.unwrap()
684				.to_string()
685				.contains("Failed to get secret from Hashicorp Cloud Vault"));
686		})
687		.await;
688	}
689
690	#[tokio::test]
691	async fn test_vault_type_clone() {
692		with_test_env(|| async {
693			let vault_type = VaultType::from_env().unwrap();
694			let cloned = vault_type.clone();
695
696			match (vault_type, cloned) {
697				(VaultType::Cloud(_), VaultType::Cloud(_)) => (), // Expected
698			}
699		})
700		.await;
701	}
702
703	#[test]
704	fn test_cloud_vault_client_new_wraps_arc() {
705		let dummy = HashicorpCloudClient::new(
706			"id".to_string(),
707			"secret".to_string(),
708			"org".to_string(),
709			"proj".to_string(),
710			"app".to_string(),
711		);
712		let client = CloudVaultClient {
713			client: Arc::new(dummy),
714		};
715		// Arc should be used internally (cannot test Arc directly, but can check type)
716		assert!(Arc::strong_count(&client.client) >= 1);
717	}
718
719	#[tokio::test]
720	async fn test_cloud_vault_client_from_env_missing_org_id() {
721		with_test_env(|| async {
722			std::env::remove_var("HCP_ORG_ID");
723			let result = CloudVaultClient::from_env();
724			assert!(result.is_err());
725			assert!(result.err().unwrap().to_string().contains("HCP_ORG_ID"));
726		})
727		.await;
728	}
729
730	#[tokio::test]
731	async fn test_cloud_vault_client_from_env_missing_project_id() {
732		with_test_env(|| async {
733			std::env::remove_var("HCP_PROJECT_ID");
734			let result = CloudVaultClient::from_env();
735			assert!(result.is_err());
736			assert!(result.err().unwrap().to_string().contains("HCP_PROJECT_ID"));
737		})
738		.await;
739	}
740
741	#[tokio::test]
742	async fn test_cloud_vault_client_from_env_missing_app_name() {
743		with_test_env(|| async {
744			std::env::remove_var("HCP_APP_NAME");
745			let result = CloudVaultClient::from_env();
746			assert!(result.is_err());
747			assert!(result.err().unwrap().to_string().contains("HCP_APP_NAME"));
748		})
749		.await;
750	}
751
752	#[tokio::test]
753	async fn test_cloud_vault_client_from_env_missing_client_id() {
754		with_test_env(|| async {
755			std::env::remove_var("HCP_CLIENT_ID");
756			let result = CloudVaultClient::from_env();
757			assert!(result.is_err());
758			assert!(result.err().unwrap().to_string().contains("HCP_CLIENT_ID"));
759		})
760		.await;
761	}
762
763	#[tokio::test]
764	async fn test_cloud_vault_client_from_env_missing_client_secret() {
765		with_test_env(|| async {
766			std::env::remove_var("HCP_CLIENT_SECRET");
767			let result = CloudVaultClient::from_env();
768			assert!(result.is_err());
769			assert!(result
770				.err()
771				.unwrap()
772				.to_string()
773				.contains("HCP_CLIENT_SECRET"));
774		})
775		.await;
776	}
777
778	#[tokio::test]
779	async fn test_vault_type_get_secret_delegates() {
780		with_test_env(|| async {
781			let vault = VaultType::from_env().unwrap();
782			let result = vault.get_secret("nonexistent").await;
783			assert!(
784				result.is_err(),
785				"Expected error for nonexistent secret, got: {:?}",
786				result
787			);
788		})
789		.await;
790	}
791
792	#[test]
793	fn test_secret_value_partial_eq_false_for_different_variants() {
794		let a = SecretValue::Plain(SecretString::new("a".to_string()));
795		let b = SecretValue::Environment("a".to_string());
796		let c = SecretValue::HashicorpCloudVault("a".to_string());
797		assert_ne!(a, b);
798		assert_ne!(a, c);
799		assert_ne!(b, c);
800	}
801
802	#[test]
803	fn test_secret_string_partial_eq() {
804		let a = SecretString::new("foo".to_string());
805		let b = SecretString::new("foo".to_string());
806		let c = SecretString::new("bar".to_string());
807		assert_eq!(a, b);
808		assert_ne!(a, c);
809	}
810
811	#[tokio::test]
812	async fn test_secret_value_resolve_env_error() {
813		let secret = SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string());
814		let result = secret.resolve().await;
815		assert!(result.is_err());
816		assert!(result
817			.err()
818			.unwrap()
819			.to_string()
820			.contains("Failed to get environment variable"));
821	}
822
823	#[tokio::test]
824	async fn test_secret_value_resolve_hashicorp_cloud_vault_error() {
825		with_test_env(|| async {
826			let secret = SecretValue::HashicorpCloudVault("NON_EXISTENT_VAULT_SECRET".to_string());
827			let result = secret.resolve().await;
828			assert!(result.is_err());
829			assert!(result
830				.err()
831				.unwrap()
832				.to_string()
833				.contains("Failed to get secret from Hashicorp Cloud Vault"));
834		})
835		.await;
836	}
837
838	#[test]
839	fn test_secret_string_debug() {
840		let secret = SecretString::new("test".to_string());
841		assert_eq!(format!("{:?}", secret), "<secret string>");
842
843		let secret = SecretValue::Plain(SecretString::new("test".to_string()));
844		assert_eq!(format!("{:?}", secret), "Plain(<secret string>)");
845
846		let secret = SecretValue::Environment("test".to_string());
847		assert_eq!(format!("{:?}", secret), "Environment(\"test\")");
848
849		let secret = SecretValue::HashicorpCloudVault("test".to_string());
850		assert_eq!(format!("{:?}", secret), "HashicorpCloudVault(\"test\")");
851	}
852
853	#[test]
854	fn test_secret_string_display() {
855		let secret = SecretString::new("test".to_string());
856		assert_eq!(format!("{}", secret), "<secret string>");
857
858		let secret = SecretValue::Plain(SecretString::new("test".to_string()));
859		assert_eq!(format!("{}", secret), "<secret string>");
860
861		let secret = SecretValue::Environment("test".to_string());
862		assert_eq!(format!("{}", secret), "test");
863
864		let secret = SecretValue::HashicorpCloudVault("test".to_string());
865		assert_eq!(format!("{}", secret), "test");
866	}
867
868	#[test]
869	fn test_secret_value_starts_with() {
870		let plain = SecretValue::Plain(SecretString::new("PREFIX_value".to_string()));
871		let env = SecretValue::Environment("PREFIX_value".to_string());
872		let vault = SecretValue::HashicorpCloudVault("PREFIX_secret".to_string());
873		assert!(plain.starts_with("PREFIX"));
874		assert!(env.starts_with("PREFIX"));
875		assert!(vault.starts_with("PREFIX"));
876		assert!(!plain.starts_with("NOPE"));
877		assert!(!env.starts_with("NOPE"));
878		assert!(!vault.starts_with("NOPE"));
879	}
880
881	#[test]
882	fn test_secret_value_is_empty() {
883		let plain = SecretValue::Plain(SecretString::new("".to_string()));
884		let env = SecretValue::Environment("".to_string());
885		let vault = SecretValue::HashicorpCloudVault("".to_string());
886		assert!(plain.is_empty());
887		assert!(env.is_empty());
888		assert!(vault.is_empty());
889
890		let plain2 = SecretValue::Plain(SecretString::new("notempty".to_string()));
891		let env2 = SecretValue::Environment("notempty".to_string());
892		let vault2 = SecretValue::HashicorpCloudVault("notempty".to_string());
893		assert!(!plain2.is_empty());
894		assert!(!env2.is_empty());
895		assert!(!vault2.is_empty());
896	}
897
898	#[test]
899	fn test_secret_value_trim() {
900		let plain = SecretValue::Plain(SecretString::new("  plainval  ".to_string()));
901		let env = SecretValue::Environment("  foo  ".to_string());
902		let vault = SecretValue::HashicorpCloudVault("  bar  ".to_string());
903		assert_eq!(plain.trim(), "plainval");
904		assert_eq!(env.trim(), "foo");
905		assert_eq!(vault.trim(), "bar");
906	}
907
908	#[test]
909	fn test_secret_value_as_str() {
910		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
911		let env = SecretValue::Environment("envval".to_string());
912		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
913		assert_eq!(plain.as_str(), "plainval");
914		assert_eq!(env.as_str(), "envval");
915		assert_eq!(vault.as_str(), "vaultval");
916	}
917
918	#[test]
919	fn test_secret_string_from_string() {
920		let s: SecretString = String::from("foo").into();
921		assert_eq!(s.as_str(), "foo");
922	}
923
924	#[test]
925	fn test_secret_value_display() {
926		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
927		let env = SecretValue::Environment("envval".to_string());
928		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
929		assert_eq!(format!("{}", plain), "<secret string>");
930		assert_eq!(format!("{}", env), "envval");
931		assert_eq!(format!("{}", vault), "vaultval");
932	}
933
934	#[test]
935	fn test_secret_value_as_ref() {
936		let plain = SecretValue::Plain(SecretString::new("plainval".to_string()));
937		let env = SecretValue::Environment("envval".to_string());
938		let vault = SecretValue::HashicorpCloudVault("vaultval".to_string());
939		assert_eq!(plain.as_ref(), "plainval");
940		assert_eq!(env.as_ref(), "envval");
941		assert_eq!(vault.as_ref(), "vaultval");
942	}
943
944	#[test]
945	fn test_case_insensitive_deserialization() {
946		// Test with uppercase variant names
947		let uppercase_json = r#"{"type":"PLAIN","value":"test_secret"}"#;
948		let uppercase_result: Result<SecretValue, _> = serde_json::from_str(uppercase_json);
949		assert!(
950			uppercase_result.is_ok(),
951			"Failed to deserialize uppercase variant: {:?}",
952			uppercase_result.err()
953		);
954
955		if let Ok(ref secret_value) = uppercase_result {
956			match secret_value {
957				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
958				_ => panic!("Expected Plain variant"),
959			}
960		}
961
962		// Test with lowercase variant names
963		let lowercase_json = r#"{"type":"plain","value":"test_secret"}"#;
964		let lowercase_result: Result<SecretValue, _> = serde_json::from_str(lowercase_json);
965		assert!(
966			lowercase_result.is_ok(),
967			"Failed to deserialize lowercase variant: {:?}",
968			lowercase_result.err()
969		);
970
971		if let Ok(ref secret_value) = lowercase_result {
972			match secret_value {
973				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
974				_ => panic!("Expected Plain variant"),
975			}
976		}
977
978		// Test with mixed case variant names
979		let mixedcase_json = r#"{"type":"pLaIn","value":"test_secret"}"#;
980		let mixedcase_result: Result<SecretValue, _> = serde_json::from_str(mixedcase_json);
981		assert!(
982			mixedcase_result.is_ok(),
983			"Failed to deserialize mixed case variant: {:?}",
984			mixedcase_result.err()
985		);
986
987		if let Ok(ref secret_value) = mixedcase_result {
988			match secret_value {
989				SecretValue::Plain(secret) => assert_eq!(secret.as_str(), "test_secret"),
990				_ => panic!("Expected Plain variant"),
991			}
992		}
993
994		// Test environment variant
995		let env_json = r#"{"type":"environment","value":"ENV_VAR"}"#;
996		let env_result: Result<SecretValue, _> = serde_json::from_str(env_json);
997		assert!(env_result.is_ok());
998
999		if let Ok(ref secret_value) = env_result {
1000			match secret_value {
1001				SecretValue::Environment(env_var) => assert_eq!(env_var, "ENV_VAR"),
1002				_ => panic!("Expected Environment variant"),
1003			}
1004		}
1005
1006		// Test vault variant
1007		let vault_json = r#"{"type":"hashicorpcloudvault","value":"secret_name"}"#;
1008		let vault_result: Result<SecretValue, _> = serde_json::from_str(vault_json);
1009		assert!(vault_result.is_ok());
1010
1011		if let Ok(ref secret_value) = vault_result {
1012			match secret_value {
1013				SecretValue::HashicorpCloudVault(name) => assert_eq!(name, "secret_name"),
1014				_ => panic!("Expected HashicorpCloudVault variant"),
1015			}
1016		}
1017	}
1018}