Skip to main content

auth_cloudflare/
config.rs

1//! Config - typed account/token/base-url/cache resolution with strict
2//! precedence and secret-safe token handling.
3//!
4//! Precedence chain (binding feedback 03/06):
5//!
6//! ```text
7//! 1. explicit constructor/config value (ConfigBuilder)
8//! 2. canonical AUTH_CLOUDFLARE_* environment variables
9//! 3. legacy Hermes-compatible aliases
10//!    (CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN,
11//!     HERMES_CUSTOM_API_CLOUDFLARE_COM_API_KEY)
12//! 4. user config file (JSON - non-secret values only)
13//! 5. typed missing-config error (CloudflareError::MissingEnv)
14//! ```
15//!
16//! The API token is held in [`SecretString`]: it never appears in `Debug`,
17//! `Display`, JSON serialization, or any error message. Callers consume it
18//! through `as_ref()`/`into()` (or [`SecretString::bearer_header`]) when
19//! building the `Authorization: Bearer <token>` header.
20
21use std::path::PathBuf;
22
23use serde::Serialize;
24
25use crate::auth::{AuthProvider, ACCOUNT_ENV, TOKEN_ENV};
26use crate::cache::HERMES_HOME_ENV;
27use crate::error::CloudflareError;
28
29/// Canonical env var for the Cloudflare account ID (non-secret).
30pub const ACCOUNT_ID_ENV: &str = "AUTH_CLOUDFLARE_ACCOUNT_ID";
31/// Canonical env var for the Cloudflare API token (secret).
32pub const API_TOKEN_ENV: &str = "AUTH_CLOUDFLARE_API_TOKEN";
33/// Optional override for the Workers AI inference base URL.
34pub const BASE_URL_ENV: &str = "AUTH_CLOUDFLARE_WORKERS_AI_BASE_URL";
35/// Optional override for the account-scoped cache directory.
36pub const CACHE_DIR_ENV: &str = "AUTH_CLOUDFLARE_CACHE_DIR";
37/// Optional override for the user config file path.
38pub const CONFIG_ENV: &str = "AUTH_CLOUDFLARE_CONFIG";
39/// Legacy Hermes-compatible token alias (feedback 03).
40pub const LEGACY_HERMES_TOKEN_ENV: &str = "HERMES_CUSTOM_API_CLOUDFLARE_COM_API_KEY";
41
42/// Expected Cloudflare account ID shape: exactly this many ASCII hex digits.
43/// (Workers & Pages → Overview → Account ID.)
44pub const ACCOUNT_ID_LEN: usize = 32;
45
46/// Default user config file name under `$HERMES_HOME/auth-cloudflare/`.
47pub const CONFIG_FILE_NAME: &str = "config.json";
48
49/// A wrapped API token that can never leak through formatting or JSON.
50///
51/// The raw value is only reachable through [`AsRef<str>`] and
52/// [`From<SecretString> for String`] - i.e. the Bearer-header construction
53/// path. `Debug`, `Display`, and `Serialize` all emit a redaction marker.
54#[derive(Clone, PartialEq, Eq)]
55pub struct SecretString(String);
56
57impl SecretString {
58	/// Wrap a token value. The caller is responsible for the value being a
59	/// real credential; this type only guarantees it never leaks.
60	pub fn new(value: impl Into<String>) -> Self {
61		Self(value.into())
62	}
63
64	/// The `Authorization: Bearer <token>` header value.
65	pub fn bearer_header(&self) -> String {
66		format!("Bearer {}", self.0)
67	}
68}
69
70impl std::fmt::Debug for SecretString {
71	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72		f.write_str("SecretString(\"<redacted>\")")
73	}
74}
75
76impl std::fmt::Display for SecretString {
77	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78		f.write_str("<redacted>")
79	}
80}
81
82impl AsRef<str> for SecretString {
83	fn as_ref(&self) -> &str {
84		&self.0
85	}
86}
87
88impl From<SecretString> for String {
89	fn from(secret: SecretString) -> Self {
90		secret.0
91	}
92}
93
94impl From<String> for SecretString {
95	fn from(value: String) -> Self {
96		Self(value)
97	}
98}
99
100impl From<&str> for SecretString {
101	fn from(value: &str) -> Self {
102		Self(value.to_string())
103	}
104}
105
106impl Serialize for SecretString {
107	/// Serializes as the literal `"<redacted>"` - the raw token can never
108	/// reach JSON output, even through a derived `Serialize` impl.
109	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110		serializer.serialize_str("<redacted>")
111	}
112}
113
114/// Fully resolved provider configuration.
115///
116/// Values are resolved once at construction through the documented
117/// precedence chain; the account ID is validated for shape and the token is
118/// held in a [`SecretString`].
119#[derive(Clone, Debug)]
120pub struct Config {
121	account_id: String,
122	api_token: SecretString,
123	base_url: Option<String>,
124	cache_dir: Option<PathBuf>,
125	config_path: PathBuf,
126}
127
128impl Config {
129	/// Resolve configuration strictly: constructor/env/config file, or a
130	/// typed [`CloudflareError::MissingEnv`] error naming the exact
131	/// environment variable that is missing.
132	pub fn from_env() -> Result<Self, CloudflareError> {
133		ConfigBuilder::new().build()
134	}
135
136	/// Resolve configuration leniently (profile bootstrap): `None` whenever
137	/// the account ID or token cannot be resolved and validated.
138	pub fn from_env_lenient() -> Option<Self> {
139		Self::from_env().ok()
140	}
141
142	/// The validated account ID (non-secret operational metadata).
143	pub fn account_id(&self) -> &str {
144		&self.account_id
145	}
146
147	/// The API token - redacted everywhere except `as_ref()`/`into()`.
148	pub fn api_token(&self) -> &SecretString {
149		&self.api_token
150	}
151
152	/// The Workers AI inference base URL: the explicit override when set,
153	/// otherwise derived as
154	/// `https://api.cloudflare.com/client/v4/accounts/<id>/ai/v1`.
155	pub fn base_url(&self) -> Result<String, CloudflareError> {
156		Ok(match &self.base_url {
157			Some(url) => url.clone(),
158			None => AuthProvider::new(&self.account_id).base_url(),
159		})
160	}
161
162	/// The account-scoped cache directory.
163	///
164	/// Explicit override wins; the default is
165	/// `$HERMES_HOME/cache/auth-cloudflare/<slug>/` where `<slug>` is the
166	/// stable 16-hex account hash and `HERMES_HOME` falls back to `~/.hermes`.
167	/// The token never enters the cache path.
168	pub fn cache_dir(&self) -> PathBuf {
169		match &self.cache_dir {
170			Some(dir) => dir.clone(),
171			None => hermes_home()
172				.join("cache")
173				.join("auth-cloudflare")
174				.join(AuthProvider::new(&self.account_id).cache_slug()),
175		}
176	}
177
178	/// The user config file path: `AUTH_CLOUDFLARE_CONFIG` override or
179	/// `$HERMES_HOME/auth-cloudflare/config.json`.
180	pub fn config_path(&self) -> PathBuf {
181		self.config_path.clone()
182	}
183}
184
185/// Builder for [`Config`] - the "explicit constructor/config value" tier of
186/// the precedence chain.
187///
188/// Any field set here is pinned above every environment variable and the
189/// config file; unset fields fall through the canonical env vars, the
190/// legacy aliases, and the user config file.
191#[derive(Clone, Debug, Default)]
192pub struct ConfigBuilder {
193	account_id: Option<String>,
194	api_token: Option<String>,
195	base_url: Option<String>,
196	cache_dir: Option<PathBuf>,
197	config_path: Option<PathBuf>,
198}
199
200impl ConfigBuilder {
201	pub fn new() -> Self {
202		Self::default()
203	}
204
205	/// Pin the account ID (non-secret).
206	pub fn account_id(mut self, value: impl Into<String>) -> Self {
207		self.account_id = Some(value.into());
208		self
209	}
210
211	/// Pin the API token (secret - held as [`SecretString`] on resolution).
212	pub fn api_token(mut self, value: impl Into<String>) -> Self {
213		self.api_token = Some(value.into());
214		self
215	}
216
217	/// Pin the Workers AI base URL override.
218	pub fn base_url(mut self, value: impl Into<String>) -> Self {
219		self.base_url = Some(value.into());
220		self
221	}
222
223	/// Pin the cache directory override.
224	pub fn cache_dir(mut self, value: impl Into<PathBuf>) -> Self {
225		self.cache_dir = Some(value.into());
226		self
227	}
228
229	/// Pin the user config file path.
230	pub fn config_path(mut self, value: impl Into<PathBuf>) -> Self {
231		self.config_path = Some(value.into());
232		self
233	}
234
235	/// Resolve through the full precedence chain.
236	pub fn build(self) -> Result<Config, CloudflareError> {
237		let config_path = self
238			.config_path
239			.or_else(|| env_nonempty(CONFIG_ENV).map(PathBuf::from))
240			.unwrap_or_else(|| hermes_home().join("auth-cloudflare").join(CONFIG_FILE_NAME));
241		let file = FileConfig::load(&config_path)?;
242
243		// 1. Account ID: constructor > canonical env > legacy alias > file.
244		let account_id = normalize(self.account_id)
245			.or_else(|| env_nonempty(ACCOUNT_ID_ENV))
246			.or_else(|| env_nonempty(ACCOUNT_ENV))
247			.or_else(|| normalize(file.account_id))
248			.ok_or_else(|| CloudflareError::MissingEnv {
249				env_var: ACCOUNT_ID_ENV,
250				hint: format!(
251					"export {ACCOUNT_ID_ENV}=<account id> (aliases: {ACCOUNT_ENV}) - found under Workers & Pages → Overview → Account ID"
252				),
253			})?;
254		if !is_valid_account_id(&account_id) {
255			return Err(CloudflareError::MissingEnv {
256				env_var: ACCOUNT_ID_ENV,
257				hint: format!(
258					"{ACCOUNT_ID_ENV} must be exactly {ACCOUNT_ID_LEN} ASCII hex digits, got {} (\"{account_id}\")",
259					account_id.len()
260				),
261			});
262		}
263
264		// 2. API token: constructor > canonical env > legacy aliases > file
265		//    (which may only name the env var holding the token - never the
266		//    value itself, per feedback 02).
267		let api_token = normalize(self.api_token)
268			.or_else(|| env_nonempty(API_TOKEN_ENV))
269			.or_else(|| env_nonempty(TOKEN_ENV))
270			.or_else(|| env_nonempty(LEGACY_HERMES_TOKEN_ENV))
271			.or_else(|| {
272				let name = file.api_token_env.as_deref().map(str::trim).filter(|n| !n.is_empty())?;
273				env_nonempty(name)
274			})
275			.ok_or_else(|| CloudflareError::MissingEnv {
276				env_var: API_TOKEN_ENV,
277				hint: format!(
278					"export {API_TOKEN_ENV}=<scoped api token> (aliases: {TOKEN_ENV}, {LEGACY_HERMES_TOKEN_ENV}) - create a token with Account → Workers AI → Write/Edit"
279				),
280			})?;
281
282		// 3. Base URL override: constructor > canonical env > file.
283		let base_url = normalize(self.base_url)
284			.or_else(|| env_nonempty(BASE_URL_ENV))
285			.or_else(|| normalize(file.base_url));
286
287		// 4. Cache dir override: constructor > canonical env > file.
288		let cache_dir = self
289			.cache_dir
290			.or_else(|| env_nonempty(CACHE_DIR_ENV).map(PathBuf::from))
291			.or_else(|| file.cache_dir.map(PathBuf::from));
292
293		Ok(Config {
294			account_id,
295			api_token: SecretString::new(api_token),
296			base_url,
297			cache_dir,
298			config_path,
299		})
300	}
301}
302
303/// Non-secret user config file (JSON).
304///
305/// The token VALUE is never stored here; `api_token_env` may name the
306/// environment variable that holds it ("the configuration file may contain
307/// the variable name but never the secret value" - binding feedback 02). A
308/// stray `api_token` value in the file is ignored by serde and never read.
309#[derive(serde::Deserialize, Default)]
310struct FileConfig {
311	account_id: Option<String>,
312	base_url: Option<String>,
313	cache_dir: Option<String>,
314	api_token_env: Option<String>,
315}
316
317impl FileConfig {
318	/// Load the config file; a missing file is an empty config, an
319	/// unreadable or malformed file is a typed error naming the file.
320	fn load(path: &std::path::Path) -> Result<Self, CloudflareError> {
321		let contents = match std::fs::read_to_string(path) {
322			Ok(contents) => contents,
323			Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
324			Err(error) => {
325				return Err(CloudflareError::MissingEnv {
326					env_var: CONFIG_ENV,
327					hint: format!("config file {} is unreadable: {error}", path.display()),
328				});
329			},
330		};
331		serde_json::from_str(&contents).map_err(|error| CloudflareError::MissingEnv {
332			env_var: CONFIG_ENV,
333			hint: format!("config file {} is not valid JSON: {error}", path.display()),
334		})
335	}
336}
337
338/// Trim an explicit value; empty/whitespace-only values count as missing
339/// (the documented "whitespace-only treated as missing" rule).
340fn normalize(value: Option<String>) -> Option<String> {
341	value.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
342}
343
344/// Read a non-empty (after trim) environment variable, if present.
345fn env_nonempty(name: &str) -> Option<String> {
346	std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
347}
348
349/// Cloudflare account IDs are 32 ASCII hex digits (lowercase in the
350/// dashboard; uppercase hex is accepted).
351fn is_valid_account_id(value: &str) -> bool {
352	value.len() == ACCOUNT_ID_LEN && value.bytes().all(|b| b.is_ascii_hexdigit())
353}
354
355/// Resolve `$HERMES_HOME`, falling back to `~/.hermes` - matches `hermes`
356/// itself and `crate::cache::cache_dir_for_account`.
357fn hermes_home() -> PathBuf {
358	env_nonempty(HERMES_HOME_ENV).map(PathBuf::from).unwrap_or_else(|| {
359		std::env::var("HOME")
360			.ok()
361			.map(PathBuf::from)
362			.unwrap_or_else(|| PathBuf::from("~"))
363			.join(".hermes")
364	})
365}
366
367#[cfg(test)]
368mod tests {
369	use super::*;
370
371	/// Cloudflare-shaped synthetic account ID (32 hex digits) - never a
372	/// real account.
373	const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
374	/// Second synthetic account ID for override comparisons.
375	const OTHER_ACCOUNT: &str = "fedcba9876543210fedcba9876543210";
376	/// Synthetic token - never a real credential.
377	const TOKEN: &str = "cfut_test_synthetic_token_0001";
378
379	/// Every env var this module reads, saved/restored for isolation.
380	const ALL_VARS: &[&str] = &[
381		ACCOUNT_ID_ENV,
382		API_TOKEN_ENV,
383		BASE_URL_ENV,
384		CACHE_DIR_ENV,
385		CONFIG_ENV,
386		ACCOUNT_ENV,
387		TOKEN_ENV,
388		LEGACY_HERMES_TOKEN_ENV,
389		HERMES_HOME_ENV,
390		"HOME",
391	];
392
393	/// `std::env` is process-global and tests run in parallel - serialize
394	/// env mutation through a static mutex and restore prior values after.
395	static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
396
397	fn with_env<F, R>(vars: &[(&str, Option<&str>)], f: F) -> R
398	where
399		F: FnOnce() -> R,
400	{
401		let _guard = ENV_LOCK.lock().unwrap();
402		let saved: Vec<(String, Option<String>)> =
403			ALL_VARS.iter().map(|k| ((*k).to_string(), std::env::var(k).ok())).collect();
404		for key in ALL_VARS {
405			std::env::remove_var(key);
406		}
407		for (key, value) in vars {
408			match value {
409				Some(value) => std::env::set_var(key, value),
410				None => std::env::remove_var(key),
411			}
412		}
413		let result = f();
414		for (key, value) in saved {
415			match value {
416				Some(value) => std::env::set_var(&key, value),
417				None => std::env::remove_var(&key),
418			}
419		}
420		result
421	}
422
423	/// Write a temp config file outside the repo; returns its path.
424	fn temp_config_file(name: &str, contents: &str) -> PathBuf {
425		let dir = std::env::temp_dir().join(format!("auth-cloudflare-config-tests-{}", std::process::id()));
426		std::fs::create_dir_all(&dir).expect("create temp dir");
427		let path = dir.join(name);
428		std::fs::write(&path, contents).expect("write config file");
429		path
430	}
431
432	#[test]
433	fn canonical_env_wins_over_legacy_alias() {
434		with_env(
435			&[
436				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
437				(ACCOUNT_ENV, Some(OTHER_ACCOUNT)),
438				(API_TOKEN_ENV, Some("cfut_test_canonical_token")),
439				(TOKEN_ENV, Some("cfut_test_legacy_token")),
440			],
441			|| {
442				let config = Config::from_env().expect("canonical vars present");
443				assert_eq!(config.account_id(), ACCOUNT);
444				assert_eq!(config.api_token().as_ref(), "cfut_test_canonical_token");
445				assert_eq!(
446					config.base_url().unwrap(),
447					format!("https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/ai/v1")
448				);
449			},
450		);
451	}
452
453	#[test]
454	fn legacy_alias_fallback() {
455		with_env(&[(ACCOUNT_ENV, Some(ACCOUNT)), (TOKEN_ENV, Some(TOKEN))], || {
456			let config = Config::from_env().expect("legacy vars present");
457			assert_eq!(config.account_id(), ACCOUNT);
458			assert_eq!(config.api_token().as_ref(), TOKEN);
459		});
460		// The Hermes custom-key alias resolves the token too.
461		with_env(
462			&[
463				(ACCOUNT_ENV, Some(ACCOUNT)),
464				(LEGACY_HERMES_TOKEN_ENV, Some("cfut_test_hermes_key")),
465			],
466			|| {
467				let config = Config::from_env().expect("hermes alias present");
468				assert_eq!(config.account_id(), ACCOUNT);
469				assert_eq!(config.api_token().as_ref(), "cfut_test_hermes_key");
470			},
471		);
472	}
473
474	#[test]
475	fn whitespace_only_treated_missing() {
476		with_env(&[(ACCOUNT_ID_ENV, Some(" \t ")), (API_TOKEN_ENV, Some(TOKEN))], || {
477			let error = Config::from_env().expect_err("whitespace account id is missing");
478			assert!(matches!(error, CloudflareError::MissingEnv { env_var: ACCOUNT_ID_ENV, .. }));
479		});
480		with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some("  "))], || {
481			let error = Config::from_env().expect_err("whitespace token is missing");
482			assert!(matches!(error, CloudflareError::MissingEnv { env_var: API_TOKEN_ENV, .. }));
483		});
484	}
485
486	#[test]
487	fn token_redacted_in_debug_display_and_json() {
488		let token = SecretString::new(TOKEN);
489		assert!(!format!("{token:?}").contains(TOKEN), "Debug must redact the token");
490		assert!(!format!("{token}").contains(TOKEN), "Display must redact the token");
491		let json = serde_json::to_string(&token).expect("serialize");
492		assert_eq!(json, "\"<redacted>\"");
493		assert!(!json.contains(TOKEN), "JSON must redact the token");
494		// The whole Config must not leak it either.
495		with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
496			let config = Config::from_env().expect("configured");
497			assert!(!format!("{config:?}").contains(TOKEN), "Config Debug must redact the token");
498		});
499	}
500
501	#[test]
502	fn bearer_header_consumes_token_via_as_ref_and_into() {
503		let token = SecretString::new(TOKEN);
504		assert_eq!(token.as_ref(), TOKEN);
505		assert_eq!(token.bearer_header(), format!("Bearer {TOKEN}"));
506		let into_string: String = token.clone().into();
507		assert_eq!(into_string, TOKEN);
508		let from_str: SecretString = TOKEN.into();
509		assert_eq!(from_str.as_ref(), TOKEN);
510	}
511
512	#[test]
513	fn base_url_derived_exactly() {
514		with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
515			let config = Config::from_env().expect("configured");
516			assert_eq!(
517				config.base_url().unwrap(),
518				"https://api.cloudflare.com/client/v4/accounts/0123456789abcdef0123456789abcdef/ai/v1"
519			);
520		});
521	}
522
523	#[test]
524	fn base_url_override_wins() {
525		with_env(
526			&[
527				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
528				(API_TOKEN_ENV, Some(TOKEN)),
529				(BASE_URL_ENV, Some("https://example.test/ai/v1")),
530			],
531			|| {
532				let config = Config::from_env().expect("configured");
533				assert_eq!(config.base_url().unwrap(), "https://example.test/ai/v1");
534			},
535		);
536	}
537
538	#[test]
539	fn invalid_account_id_shape_rejected() {
540		// Too short, 30 hex digits, internal whitespace, non-hex characters.
541		for bad in [
542			"abc123",
543			"0123456789abcdef0123456789abcd",
544			"0123456789abcdef 0123456789abcdef",
545			"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
546		] {
547			with_env(&[(ACCOUNT_ID_ENV, Some(bad)), (API_TOKEN_ENV, Some(TOKEN))], || {
548				let error = Config::from_env().expect_err("invalid shape must be rejected");
549				assert!(matches!(error, CloudflareError::MissingEnv { env_var: ACCOUNT_ID_ENV, .. }));
550				assert!(Config::from_env_lenient().is_none(), "lenient must drop invalid shape");
551			});
552		}
553	}
554
555	#[test]
556	fn account_id_accepts_uppercase_hex() {
557		with_env(
558			&[
559				(ACCOUNT_ID_ENV, Some("0123456789ABCDEF0123456789ABCDEF")),
560				(API_TOKEN_ENV, Some(TOKEN)),
561			],
562			|| {
563				assert!(Config::from_env().is_ok(), "uppercase hex is a valid account id");
564			},
565		);
566	}
567
568	#[test]
569	fn config_file_provides_account_and_token_env_name() {
570		let path = temp_config_file(
571			"config-env-name.json",
572			&format!(r#"{{"account_id":"{ACCOUNT}","api_token_env":"MY_CF_TOKEN_VAR"}}"#),
573		);
574		with_env(
575			&[(CONFIG_ENV, Some(path.to_str().unwrap())), ("MY_CF_TOKEN_VAR", Some(TOKEN))],
576			|| {
577				let config = Config::from_env().expect("config file fallback");
578				assert_eq!(config.account_id(), ACCOUNT);
579				assert_eq!(config.api_token().as_ref(), TOKEN);
580				assert_eq!(config.config_path(), path);
581			},
582		);
583	}
584
585	#[test]
586	fn config_file_token_value_is_never_read() {
587		// A secret value placed in the config file must be ignored - the
588		// file may only reference the token by env-var name.
589		let path = temp_config_file(
590			"config-token-ignored.json",
591			&format!(r#"{{"account_id":"{ACCOUNT}","api_token":"cfut_test_should_be_ignored"}}"#),
592		);
593		with_env(&[(CONFIG_ENV, Some(path.to_str().unwrap()))], || {
594			let error = Config::from_env().expect_err("token value in file must not satisfy resolution");
595			assert!(matches!(error, CloudflareError::MissingEnv { env_var: API_TOKEN_ENV, .. }));
596		});
597	}
598
599	#[test]
600	fn config_file_missing_is_not_an_error() {
601		with_env(
602			&[
603				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
604				(API_TOKEN_ENV, Some(TOKEN)),
605				(CONFIG_ENV, Some("/nonexistent/auth-cloudflare/config.json")),
606			],
607			|| {
608				let config = Config::from_env().expect("missing config file is fine");
609				assert_eq!(config.account_id(), ACCOUNT);
610			},
611		);
612	}
613
614	#[test]
615	fn cache_dir_defaults_to_hermes_home_slug() {
616		with_env(
617			&[
618				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
619				(API_TOKEN_ENV, Some(TOKEN)),
620				(HERMES_HOME_ENV, Some("/tmp/auth-cloudflare-hermes-home")),
621			],
622			|| {
623				let config = Config::from_env().expect("configured");
624				let expected = PathBuf::from("/tmp/auth-cloudflare-hermes-home")
625					.join("cache")
626					.join("auth-cloudflare")
627					.join(AuthProvider::new(ACCOUNT).cache_slug());
628				assert_eq!(config.cache_dir(), expected);
629			},
630		);
631	}
632
633	#[test]
634	fn cache_dir_override_wins() {
635		with_env(
636			&[
637				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
638				(API_TOKEN_ENV, Some(TOKEN)),
639				(CACHE_DIR_ENV, Some("/tmp/custom-cache")),
640			],
641			|| {
642				let config = Config::from_env().expect("configured");
643				assert_eq!(config.cache_dir(), PathBuf::from("/tmp/custom-cache"));
644			},
645		);
646	}
647
648	#[test]
649	fn config_path_defaults_under_hermes_home() {
650		with_env(
651			&[
652				(ACCOUNT_ID_ENV, Some(ACCOUNT)),
653				(API_TOKEN_ENV, Some(TOKEN)),
654				(HERMES_HOME_ENV, Some("/tmp/auth-cloudflare-hermes-home")),
655			],
656			|| {
657				let config = Config::from_env().expect("configured");
658				let expected = PathBuf::from("/tmp/auth-cloudflare-hermes-home")
659					.join("auth-cloudflare")
660					.join("config.json");
661				assert_eq!(config.config_path(), expected);
662			},
663		);
664	}
665
666	#[test]
667	fn explicit_constructor_wins_over_env() {
668		with_env(
669			&[
670				(ACCOUNT_ID_ENV, Some(OTHER_ACCOUNT)),
671				(API_TOKEN_ENV, Some("cfut_test_env_token")),
672			],
673			|| {
674				let config = ConfigBuilder::new()
675					.account_id(ACCOUNT)
676					.api_token("cfut_test_explicit_token")
677					.build()
678					.expect("explicit values");
679				assert_eq!(config.account_id(), ACCOUNT);
680				assert_eq!(config.api_token().as_ref(), "cfut_test_explicit_token");
681			},
682		);
683	}
684
685	#[test]
686	fn from_env_lenient_returns_none_when_missing() {
687		with_env(&[], || {
688			assert!(Config::from_env_lenient().is_none());
689		});
690		with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
691			let config = Config::from_env_lenient().expect("complete env");
692			assert_eq!(config.account_id(), ACCOUNT);
693		});
694	}
695
696	#[test]
697	fn missing_env_error_names_the_var() {
698		with_env(&[(API_TOKEN_ENV, Some(TOKEN))], || {
699			let error = Config::from_env().expect_err("account missing");
700			assert!(error.to_string().contains(ACCOUNT_ID_ENV));
701		});
702		with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT))], || {
703			let error = Config::from_env().expect_err("token missing");
704			assert!(error.to_string().contains(API_TOKEN_ENV));
705			assert!(!error.to_string().contains("cfut_"), "error must not echo token prefixes");
706		});
707	}
708}