1use std::{
7 collections::HashMap,
8 path::{Path, PathBuf},
9 time::Duration,
10};
11
12use bitwarden_access_token::{AccessToken, AccessTokenKind};
13use uuid::Uuid;
14
15use crate::{
16 cli::RunArgs,
17 error::AccessConnectorError,
18 executor::{AccessConnectorConfig, retry::RetryCfg},
19 resolver::config::TargetEntry,
20};
21
22const MIN_POLL_INTERVAL_SECS: u64 = 15;
23
24const MAX_HEARTBEAT_INTERVAL_SECS: u64 = 120;
26
27#[derive(Debug, Default, serde::Deserialize)]
31#[serde(default, deny_unknown_fields)]
32struct EnvironmentConfig {
33 base: Option<String>,
37 api: Option<String>,
39 identity: Option<String>,
41}
42
43enum Environment {
45 Us,
46 Eu,
47 SelfHosted { api: String, identity: String },
48}
49
50impl Environment {
51 fn from_base(base: &str) -> Self {
55 let url = url::Url::parse(base).ok();
56 match url.as_ref().and_then(url::Url::host_str) {
57 Some("bitwarden.com" | "vault.bitwarden.com") => Self::Us,
58 Some("bitwarden.eu" | "vault.bitwarden.eu") => Self::Eu,
59 _ => {
60 let base = base.trim_end_matches('/');
61 Self::SelfHosted {
62 api: format!("{base}/api"),
63 identity: format!("{base}/identity"),
64 }
65 }
66 }
67 }
68
69 fn api_url(&self) -> &str {
70 match self {
71 Self::Us => "https://api.bitwarden.com",
72 Self::Eu => "https://api.bitwarden.eu",
73 Self::SelfHosted { api, .. } => api,
74 }
75 }
76
77 fn identity_url(&self) -> &str {
78 match self {
79 Self::Us => "https://identity.bitwarden.com",
80 Self::Eu => "https://identity.bitwarden.eu",
81 Self::SelfHosted { identity, .. } => identity,
82 }
83 }
84}
85
86#[derive(Debug, serde::Deserialize)]
89#[serde(default, deny_unknown_fields)]
90struct FileConfig {
91 environment: EnvironmentConfig,
92 poll_interval: u64,
94 heartbeat_interval: u64,
96 offline_grace: u64,
98 max_retry_attempts: u32,
100 retry_base_delay: u64,
102 script_root: Option<PathBuf>,
104 script_timeout: u64,
106 powershell_path: Option<PathBuf>,
108 powershell_execution_policy: String,
112 entra_verify_probe: bool,
114 targets: HashMap<Uuid, TargetEntry>,
116}
117
118impl Default for FileConfig {
120 fn default() -> Self {
121 Self {
122 environment: EnvironmentConfig::default(),
123 poll_interval: 15,
124 heartbeat_interval: 30,
125 offline_grace: 60,
126 max_retry_attempts: 5,
127 retry_base_delay: 1,
128 script_root: None,
129 script_timeout: 60,
130 powershell_path: None,
131 powershell_execution_policy: "Bypass".to_string(),
132 entra_verify_probe: false,
133 targets: HashMap::new(),
134 }
135 }
136}
137
138impl FileConfig {
139 fn load(path: &Path) -> Result<Self, AccessConnectorError> {
141 let contents = std::fs::read_to_string(path).map_err(|e| {
142 AccessConnectorError::InvalidConfig(format!(
143 "cannot read config file {}: {e}",
144 path.display()
145 ))
146 })?;
147 Self::parse(&contents, path)
148 }
149
150 fn parse(contents: &str, path: &Path) -> Result<Self, AccessConnectorError> {
153 toml::from_str(contents).map_err(|e| {
154 let summary = e.to_string();
155 let description = summary.lines().last().unwrap_or("parse error");
156 AccessConnectorError::InvalidConfig(format!(
157 "config file {} is invalid TOML: {description}",
158 path.display()
159 ))
160 })
161 }
162}
163
164fn non_blank(v: Option<String>) -> Option<String> {
166 v.filter(|v| !v.trim().is_empty())
167}
168
169fn parse_token(raw: Option<String>) -> Result<AccessToken, AccessConnectorError> {
171 let raw = non_blank(raw).ok_or_else(|| {
172 AccessConnectorError::InvalidConfig(
173 "access connector token must be supplied via the BWAC_TOKEN environment variable"
174 .into(),
175 )
176 })?;
177 AccessToken::parse(raw.trim(), AccessTokenKind::AccessConnector)
178 .map_err(|e| AccessConnectorError::InvalidToken(format!("{e}")))
179}
180
181fn missing_url(name: &str) -> AccessConnectorError {
183 AccessConnectorError::InvalidConfig(format!(
184 "{name} URL must be supplied via the BWAC_{}_URL environment variable, \
185 [environment].{name}, or [environment].base in the config file",
186 name.to_uppercase()
187 ))
188}
189
190pub struct Config {
192 inner: AccessConnectorConfig,
193}
194
195impl std::fmt::Debug for Config {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("Config")
198 .field("api_url", &self.inner.api_url)
199 .field("identity_url", &self.inner.identity_url)
200 .finish_non_exhaustive()
201 }
202}
203
204impl Config {
205 pub fn from_cli(args: RunArgs) -> Result<Self, AccessConnectorError> {
213 let env_token = std::env::var("BWAC_TOKEN").ok();
216 if env_token.is_some() {
217 unsafe {
218 std::env::remove_var("BWAC_TOKEN");
219 }
220 }
221
222 let token = parse_token(env_token)?;
223
224 let file = match &args.config {
225 Some(path) => FileConfig::load(path)?,
226 None => FileConfig::default(),
227 };
228
229 let env_url = |name: &str| non_blank(std::env::var(name).ok());
230 Self::build(
231 token,
232 file,
233 env_url("BWAC_API_URL"),
234 env_url("BWAC_IDENTITY_URL"),
235 )
236 }
237
238 fn build(
241 token: AccessToken,
242 file: FileConfig,
243 env_api: Option<String>,
244 env_identity: Option<String>,
245 ) -> Result<Self, AccessConnectorError> {
246 let api = env_api.or(file.environment.api.clone());
247 let identity = env_identity.or(file.environment.identity.clone());
248 if file.environment.base.is_none() {
250 match (&api, &identity) {
251 (Some(_), None) => return Err(missing_url("identity")),
252 (None, Some(_)) => return Err(missing_url("api")),
253 _ => {}
254 }
255 }
256 let server = file
257 .environment
258 .base
259 .as_deref()
260 .map(Environment::from_base)
261 .unwrap_or(Environment::Us);
262 let api_url = api.unwrap_or_else(|| server.api_url().to_owned());
263 let identity_url = identity.unwrap_or_else(|| server.identity_url().to_owned());
264
265 if file.poll_interval < MIN_POLL_INTERVAL_SECS {
266 return Err(AccessConnectorError::InvalidConfig(format!(
267 "poll_interval must be >= {MIN_POLL_INTERVAL_SECS} seconds (got {})",
268 file.poll_interval
269 )));
270 }
271
272 if file.heartbeat_interval >= MAX_HEARTBEAT_INTERVAL_SECS {
273 return Err(AccessConnectorError::InvalidConfig(format!(
274 "heartbeat_interval must be < {MAX_HEARTBEAT_INTERVAL_SECS} seconds (got {})",
275 file.heartbeat_interval
276 )));
277 }
278
279 for (name, value) in [
282 ("heartbeat_interval", file.heartbeat_interval),
283 ("offline_grace", file.offline_grace),
284 ("script_timeout", file.script_timeout),
285 ] {
286 if value == 0 {
287 return Err(AccessConnectorError::InvalidConfig(format!(
288 "{name} must be > 0 seconds"
289 )));
290 }
291 }
292
293 Ok(Config {
294 inner: AccessConnectorConfig {
295 api_url,
296 identity_url,
297 token,
298 poll_interval: Duration::from_secs(file.poll_interval),
299 heartbeat_interval: Duration::from_secs(file.heartbeat_interval),
300 offline_grace: Duration::from_secs(file.offline_grace),
301 retry_cfg: RetryCfg {
302 max_retry_attempts: file.max_retry_attempts,
303 retry_base_delay: Duration::from_secs(file.retry_base_delay),
304 },
305 script_root: file.script_root,
306 script_timeout: Duration::from_secs(file.script_timeout),
307 powershell_path: file.powershell_path,
308 powershell_execution_policy: file.powershell_execution_policy,
309 entra_verify_probe: file.entra_verify_probe,
310 targets: file.targets,
311 },
312 })
313 }
314
315 pub fn into_access_connector_config(self) -> AccessConnectorConfig {
317 self.inner
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::TEST_ENV_LOCK as ENV_LOCK;
325
326 const VALID_TOKEN: &str = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
327
328 const URLS: &str = r#"
330[environment]
331api = "https://api.example.com"
332identity = "https://identity.example.com"
333"#;
334
335 fn token() -> AccessToken {
336 parse_token(Some(VALID_TOKEN.into())).expect("valid token")
337 }
338
339 fn file(toml: &str) -> FileConfig {
340 FileConfig::parse(toml, Path::new("test.toml")).expect("valid config file")
341 }
342
343 fn parse_err(toml: &str) -> AccessConnectorError {
344 match FileConfig::parse(toml, Path::new("test.toml")) {
345 Err(e) => e,
346 Ok(_) => panic!("expected parse error for {toml:?}"),
347 }
348 }
349
350 fn build(toml: &str) -> Result<Config, AccessConnectorError> {
351 Config::build(token(), file(toml), None, None)
352 }
353
354 fn invalid_config_message(e: AccessConnectorError) -> String {
355 match e {
356 AccessConnectorError::InvalidConfig(msg) => msg,
357 other => panic!("expected InvalidConfig, got {other:?}"),
358 }
359 }
360
361 #[test]
362 fn interval_bounds() {
363 let rows = [
364 ("poll 14", "poll_interval = 14", false),
365 ("poll 15", "poll_interval = 15", true),
366 ("heartbeat 119", "heartbeat_interval = 119", true),
367 ("heartbeat 120", "heartbeat_interval = 120", false),
368 ("heartbeat 0", "heartbeat_interval = 0", false),
369 ("offline_grace 0", "offline_grace = 0", false),
370 ("script_timeout 0", "script_timeout = 0", false),
371 ];
372 for (label, line, ok) in rows {
373 let result = build(&format!("{line}\n{URLS}"));
374 if ok {
375 assert!(result.is_ok(), "{label}: expected Ok, got {result:?}");
376 } else {
377 assert!(
378 matches!(result, Err(AccessConnectorError::InvalidConfig(_))),
379 "{label}: expected InvalidConfig, got {result:?}"
380 );
381 }
382 }
383 }
384
385 #[test]
386 fn url_resolution() {
387 const BASE: &str = "[environment]\nbase = \"https://bitwarden.example.com\"\n";
388 const ENV_API: &str = "https://api.env.example.com";
389 const ENV_ID: &str = "https://identity.env.example.com";
390 const DERIVED_API: &str = "https://bitwarden.example.com/api";
391 const DERIVED_ID: &str = "https://bitwarden.example.com/identity";
392 let blank = || Some(" ".to_string());
393 let env = |v: &str| Some(v.to_string());
394
395 let us_api = Environment::Us.api_url();
397 let us_id = Environment::Us.identity_url();
398 let eu_api = Environment::Eu.api_url();
399 let eu_id = Environment::Eu.identity_url();
400 let rows = [
401 ("env only", "", env(ENV_API), env(ENV_ID), ENV_API, ENV_ID),
402 ("nothing configured", "", None, None, us_api, us_id),
403 (
404 "no environment section",
405 "poll_interval = 15\n",
406 None,
407 None,
408 us_api,
409 us_id,
410 ),
411 (
412 "us cloud base",
413 "[environment]\nbase = \"https://bitwarden.com\"\n",
414 None,
415 None,
416 us_api,
417 us_id,
418 ),
419 (
420 "us vault base with trailing slash",
421 "[environment]\nbase = \"https://vault.bitwarden.com/\"\n",
422 None,
423 None,
424 us_api,
425 us_id,
426 ),
427 (
428 "scheme-less cloud host is self-hosted",
429 "[environment]\nbase = \"bitwarden.eu\"\n",
430 None,
431 None,
432 "bitwarden.eu/api",
433 "bitwarden.eu/identity",
434 ),
435 (
436 "eu base with port",
437 "[environment]\nbase = \"https://bitwarden.eu:8443\"\n",
438 None,
439 None,
440 eu_api,
441 eu_id,
442 ),
443 (
444 "cloud host as subdomain of attacker derives paths",
445 "[environment]\nbase = \"https://bitwarden.com.evil.example\"\n",
446 None,
447 None,
448 "https://bitwarden.com.evil.example/api",
449 "https://bitwarden.com.evil.example/identity",
450 ),
451 (
452 "eu base mixed case",
453 "[environment]\nbase = \"https://VAULT.Bitwarden.EU\"\n",
454 None,
455 None,
456 eu_api,
457 eu_id,
458 ),
459 (
460 "eu base with explicit api",
461 "[environment]\nbase = \"https://bitwarden.eu\"\napi = \"https://custom-api.example.com\"\n",
462 None,
463 None,
464 "https://custom-api.example.com",
465 eu_id,
466 ),
467 (
468 "cloud base with env api",
469 "[environment]\nbase = \"https://bitwarden.eu\"\n",
470 env(ENV_API),
471 None,
472 ENV_API,
473 eu_id,
474 ),
475 (
476 "cloud lookalike derives paths",
477 "[environment]\nbase = \"https://notbitwarden.com\"\n",
478 None,
479 None,
480 "https://notbitwarden.com/api",
481 "https://notbitwarden.com/identity",
482 ),
483 (
484 "env over file",
485 URLS,
486 env(ENV_API),
487 env(ENV_ID),
488 ENV_API,
489 ENV_ID,
490 ),
491 (
492 "blank env is unset",
493 URLS,
494 blank(),
495 blank(),
496 "https://api.example.com",
497 "https://identity.example.com",
498 ),
499 ("base only", BASE, None, None, DERIVED_API, DERIVED_ID),
500 (
501 "base with trailing slash",
502 "[environment]\nbase = \"https://bitwarden.example.com/\"\n",
503 None,
504 None,
505 DERIVED_API,
506 DERIVED_ID,
507 ),
508 (
509 "explicit api over base",
510 "[environment]\nbase = \"https://bitwarden.example.com\"\napi = \"https://custom-api.example.com/v2\"\n",
511 None,
512 None,
513 "https://custom-api.example.com/v2",
514 DERIVED_ID,
515 ),
516 (
517 "env over explicit and base",
518 "[environment]\nbase = \"https://bitwarden.example.com\"\napi = \"https://api.file.example.com\"\nidentity = \"https://identity.file.example.com\"\n",
519 env(ENV_API),
520 env(ENV_ID),
521 ENV_API,
522 ENV_ID,
523 ),
524 ];
525 for (label, toml, env_api, env_identity, api, identity) in rows {
526 let cfg = Config::build(
527 token(),
528 file(toml),
529 non_blank(env_api),
530 non_blank(env_identity),
531 )
532 .unwrap_or_else(|e| panic!("{label}: expected Ok, got {e:?}"))
533 .into_access_connector_config();
534 assert_eq!(cfg.api_url, api, "{label}: api");
535 assert_eq!(cfg.identity_url, identity, "{label}: identity");
536 }
537 }
538
539 #[test]
540 fn missing_urls() {
541 let rows: [(&str, &str, &[&str]); 2] = [
543 (
544 "missing api",
545 "[environment]\nidentity = \"https://identity.example.com\"\n",
546 &["api URL"],
547 ),
548 (
549 "missing identity",
550 "[environment]\napi = \"https://api.example.com\"\n",
551 &["identity URL"],
552 ),
553 ];
554 for (label, toml, expected) in rows {
555 let msg = invalid_config_message(build(toml).unwrap_err());
556 for needle in expected {
557 assert!(
558 msg.contains(needle),
559 "{label}: expected {needle:?} in {msg:?}"
560 );
561 }
562 }
563
564 let env_api = Some("https://api.env.example.com".to_string());
566 let msg =
567 invalid_config_message(Config::build(token(), file(""), env_api, None).unwrap_err());
568 assert!(msg.contains("identity URL"), "env api only: {msg:?}");
569 }
570
571 #[test]
572 fn non_blank_treats_whitespace_as_unset() {
573 assert_eq!(non_blank(Some(" ".into())), None);
574 assert_eq!(non_blank(Some("".into())), None);
575 assert_eq!(non_blank(None), None);
576 assert_eq!(non_blank(Some("x".into())), Some("x".into()));
577 }
578
579 #[test]
580 fn rejected_keys() {
581 let rows = [
583 ("token key", "token = \"0.x.y:z\"\n", Some("0.x.y:z")),
584 (
585 "old top-level urls",
586 "api_url = \"https://api.example.com\"\nidentity_url = \"https://identity.example.com\"\n",
587 None,
588 ),
589 (
590 "target client_secret",
591 "[targets.00000000-0000-0000-0000-000000000001]\nclient_secret = \"supersecret\"\n",
592 Some("supersecret"),
593 ),
594 (
595 "target bind_password",
596 "[targets.00000000-0000-0000-0000-000000000001]\nldap_host = \"dc01.corp.example\"\nbind_password = \"bind-supersecret\"\n",
597 Some("bind-supersecret"),
598 ),
599 (
600 "target non-uuid key",
601 "[targets.not-a-uuid]\nscript = \"/some/script.sh\"\n",
602 None,
603 ),
604 ];
605 for (label, toml, secret) in rows {
606 let msg = invalid_config_message(parse_err(&format!("{toml}{URLS}")));
607 if let Some(secret) = secret {
608 assert!(!msg.contains(secret), "{label}: error echoed value: {msg}");
609 }
610 }
611 }
612
613 #[test]
614 fn token_parsing() {
615 for (label, raw) in [("missing", None), ("blank", Some(" ".to_string()))] {
616 assert!(
617 matches!(
618 parse_token(raw),
619 Err(AccessConnectorError::InvalidConfig(_))
620 ),
621 "{label}: expected InvalidConfig"
622 );
623 }
624
625 let bad = "not-a-valid-token-string";
626 match parse_token(Some(bad.into())) {
627 Err(AccessConnectorError::InvalidToken(msg)) => {
628 assert!(!msg.contains(bad), "malformed: error echoed token: {msg}");
629 }
630 _ => panic!("malformed: expected InvalidToken"),
631 }
632 }
633
634 #[test]
635 fn file_values_are_used() {
636 let toml = format!(
637 r#"
638poll_interval = 30
639heartbeat_interval = 45
640offline_grace = 90
641max_retry_attempts = 3
642retry_base_delay = 2
643script_timeout = 120
644entra_verify_probe = true
645{URLS}
646[targets.85808642-baba-4b8e-8c34-b48000d60a0a]
647script = "/opt/scripts/rotate.sh"
648
649[targets.00000000-0000-0000-0000-000000000001]
650tenant_id = "my-tenant"
651client_id = "my-client"
652"#
653 );
654 let cfg = build(&toml)
655 .expect("expected Ok")
656 .into_access_connector_config();
657 assert_eq!(cfg.api_url, "https://api.example.com");
658 assert_eq!(cfg.identity_url, "https://identity.example.com");
659 assert_eq!(cfg.poll_interval, Duration::from_secs(30));
660 assert_eq!(cfg.heartbeat_interval, Duration::from_secs(45));
661 assert_eq!(cfg.offline_grace, Duration::from_secs(90));
662 assert_eq!(cfg.retry_cfg.max_retry_attempts, 3);
663 assert_eq!(cfg.retry_cfg.retry_base_delay, Duration::from_secs(2));
664 assert_eq!(cfg.script_timeout, Duration::from_secs(120));
665 assert!(cfg.entra_verify_probe);
666
667 assert_eq!(cfg.targets.len(), 2);
668 let script: Uuid = "85808642-baba-4b8e-8c34-b48000d60a0a".parse().unwrap();
669 assert_eq!(
670 cfg.targets[&script].script.as_deref(),
671 Some("/opt/scripts/rotate.sh")
672 );
673 let entra: Uuid = "00000000-0000-0000-0000-000000000001".parse().unwrap();
674 assert_eq!(cfg.targets[&entra].tenant_id.as_deref(), Some("my-tenant"));
675 assert_eq!(cfg.targets[&entra].client_id.as_deref(), Some("my-client"));
676 }
677
678 #[test]
679 fn active_directory_target_values_are_used() {
680 let toml = format!(
681 r#"{URLS}
682[targets.00000000-0000-0000-0000-000000000002]
683ldap_host = "dc01.corp.example"
684bind_dn = "CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example"
685base_dn = "DC=corp,DC=example"
686ca_certificate = "/etc/pki/ad-ca.pem"
687"#
688 );
689 let cfg = build(&toml)
690 .expect("expected Ok")
691 .into_access_connector_config();
692 let ad: Uuid = "00000000-0000-0000-0000-000000000002".parse().unwrap();
693 let entry = &cfg.targets[&ad];
694 assert_eq!(entry.ldap_host.as_deref(), Some("dc01.corp.example"));
695 assert_eq!(
696 entry.bind_dn.as_deref(),
697 Some("CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example")
698 );
699 assert_eq!(entry.base_dn.as_deref(), Some("DC=corp,DC=example"));
700 assert_eq!(entry.ca_certificate.as_deref(), Some("/etc/pki/ad-ca.pem"));
701 }
702
703 #[test]
704 fn defaults_apply_when_file_omits_tunables() {
705 let cfg = build(URLS)
706 .expect("expected Ok")
707 .into_access_connector_config();
708 let defaults = FileConfig::default();
709 assert_eq!(
710 cfg.poll_interval,
711 Duration::from_secs(defaults.poll_interval)
712 );
713 assert_eq!(
714 cfg.heartbeat_interval,
715 Duration::from_secs(defaults.heartbeat_interval)
716 );
717 assert_eq!(
718 cfg.offline_grace,
719 Duration::from_secs(defaults.offline_grace)
720 );
721 assert_eq!(
722 cfg.retry_cfg.max_retry_attempts,
723 defaults.max_retry_attempts
724 );
725 assert_eq!(
726 cfg.retry_cfg.retry_base_delay,
727 Duration::from_secs(defaults.retry_base_delay)
728 );
729 assert_eq!(
730 cfg.script_timeout,
731 Duration::from_secs(defaults.script_timeout)
732 );
733 assert_eq!(cfg.entra_verify_probe, defaults.entra_verify_probe);
734 assert!(
735 cfg.targets.is_empty(),
736 "absent [targets] must be an empty map"
737 );
738 }
739
740 #[test]
741 fn from_cli_removes_token_from_environment() {
742 let _guard = ENV_LOCK.lock().unwrap();
743 unsafe {
745 std::env::set_var("BWAC_TOKEN", VALID_TOKEN);
746 std::env::set_var("BWAC_API_URL", "https://api.example.com");
747 std::env::set_var("BWAC_IDENTITY_URL", "https://identity.example.com");
748 }
749 let result = Config::from_cli(RunArgs { config: None });
750 unsafe {
752 std::env::remove_var("BWAC_API_URL");
753 std::env::remove_var("BWAC_IDENTITY_URL");
754 }
755 assert!(result.is_ok(), "expected Ok, got {result:?}");
756 assert!(
757 std::env::var("BWAC_TOKEN").is_err(),
758 "BWAC_TOKEN must be absent from environment after from_cli consumes it"
759 );
760 }
761
762 #[test]
764 fn from_cli_reads_config_file_and_ignores_blank_env_urls() {
765 use std::io::Write;
766
767 let _guard = ENV_LOCK.lock().unwrap();
768 let mut config_file = tempfile::NamedTempFile::new().expect("temp config file");
769 config_file
770 .write_all(URLS.as_bytes())
771 .expect("write config file");
772 config_file.flush().expect("flush config file");
773 unsafe {
775 std::env::set_var("BWAC_TOKEN", VALID_TOKEN);
776 std::env::set_var("BWAC_API_URL", " ");
777 std::env::remove_var("BWAC_IDENTITY_URL");
778 }
779 let result = Config::from_cli(RunArgs {
780 config: Some(config_file.path().to_path_buf()),
781 });
782 unsafe {
784 std::env::remove_var("BWAC_API_URL");
785 std::env::remove_var("BWAC_TOKEN");
786 }
787 let cfg = result.expect("expected Ok").into_access_connector_config();
788 assert_eq!(cfg.api_url, "https://api.example.com");
789 assert_eq!(cfg.identity_url, "https://identity.example.com");
790 }
791
792 #[test]
793 fn nonexistent_config_path_is_invalid_config() {
794 let result = FileConfig::load(Path::new("/nonexistent/path/to/config.toml"));
795 assert!(
796 matches!(result, Err(AccessConnectorError::InvalidConfig(_))),
797 "expected InvalidConfig for nonexistent config path, got {result:?}"
798 );
799 }
800}