1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Context;
5
6use crate::Result;
7use crate::suite::resolve::{
8 resolve_gql_url_for_env, resolve_path_from_repo_root, resolve_rest_base_url_for_env,
9};
10use crate::suite::schema::{SuiteAuth, SuiteDefaults};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum AuthProvider {
14 Rest,
15 Graphql,
16}
17
18#[derive(Debug)]
19pub enum AuthInit {
20 Disabled { message: Option<String> },
21 Enabled(Box<SuiteAuthManager>),
22}
23
24#[derive(Debug)]
25pub struct SuiteAuthManager {
26 provider: AuthProvider,
27 provider_label: String,
28 secret_json: serde_json::Value,
29 auth: SuiteAuth,
30 tokens: HashMap<String, String>,
31 errors: HashMap<String, String>,
32}
33
34impl SuiteAuthManager {
35 pub fn provider_label(&self) -> &str {
36 &self.provider_label
37 }
38
39 pub fn init_from_suite(auth: SuiteAuth, suite_defaults: &SuiteDefaults) -> Result<AuthInit> {
40 let provider = canonical_provider(&auth)?;
41 let provider_label = match provider {
42 AuthProvider::Rest => "rest".to_string(),
43 AuthProvider::Graphql => "graphql".to_string(),
44 };
45
46 let secret_env = auth.secret_env.trim().to_string();
47 if secret_env.is_empty() {
48 anyhow::bail!("Invalid suite auth block: .auth.secretEnv is empty");
49 }
50
51 let raw = std::env::var(&secret_env).ok().unwrap_or_default();
52 let raw = raw.trim().to_string();
53 if raw.is_empty() {
54 if !auth.required {
55 return Ok(AuthInit::Disabled {
56 message: Some(format!(
57 "api-test-runner: auth disabled (missing {secret_env} and auth.required=false)"
58 )),
59 });
60 }
61 anyhow::bail!("Missing auth secret env var for suite auth: {secret_env}");
62 }
63
64 let secret_json: serde_json::Value =
65 serde_json::from_str(&raw).with_context(|| format!("Invalid JSON in {secret_env}"))?;
66
67 let auth = inherit_auth_defaults(auth, suite_defaults);
69
70 Ok(AuthInit::Enabled(Box::new(SuiteAuthManager {
71 provider,
72 provider_label,
73 secret_json,
74 auth,
75 tokens: HashMap::new(),
76 errors: HashMap::new(),
77 })))
78 }
79
80 pub fn ensure_token(
81 &mut self,
82 profile: &str,
83 repo_root: &Path,
84 suite_defaults: &SuiteDefaults,
85 env_rest_url: &str,
86 env_gql_url: &str,
87 ) -> std::result::Result<String, String> {
88 self.ensure_token_with_login(profile, |mgr, profile| match mgr.provider {
89 AuthProvider::Rest => mgr.login_rest(profile, repo_root, suite_defaults, env_rest_url),
90 AuthProvider::Graphql => {
91 mgr.login_graphql(profile, repo_root, suite_defaults, env_gql_url)
92 }
93 })
94 }
95
96 fn ensure_token_with_login<F>(
97 &mut self,
98 profile: &str,
99 login: F,
100 ) -> std::result::Result<String, String>
101 where
102 F: FnOnce(&SuiteAuthManager, &str) -> std::result::Result<String, String>,
103 {
104 let profile = profile.trim();
105 if profile.is_empty() {
106 return Err(format!(
107 "auth_login_failed(provider={},profile=)",
108 self.provider_label
109 ));
110 }
111
112 if let Some(token) = self.tokens.get(profile) {
113 return Ok(token.clone());
114 }
115 if let Some(err) = self.errors.get(profile) {
116 return Err(err.clone());
117 }
118
119 let result = {
120 let mgr: &SuiteAuthManager = &*self;
121 login(mgr, profile)
122 };
123
124 match result {
125 Ok(token) => {
126 self.tokens.insert(profile.to_string(), token.clone());
127 Ok(token)
128 }
129 Err(err) => {
130 let fallback = format!(
131 "auth_login_failed(provider={},profile={profile})",
132 self.provider_label
133 );
134 let err = if err.trim().is_empty() { fallback } else { err };
135 self.errors.insert(profile.to_string(), err.clone());
136 Err(err)
137 }
138 }
139 }
140
141 fn render_credentials(
142 &self,
143 profile: &str,
144 expr: &str,
145 provider: &str,
146 ) -> std::result::Result<serde_json::Value, String> {
147 let mut vars = std::collections::BTreeMap::new();
148 vars.insert(
149 "profile".to_string(),
150 serde_json::Value::String(profile.to_string()),
151 );
152
153 let out = crate::jq::query_with_vars(&self.secret_json, expr, &vars).map_err(|_| {
154 format!("auth_credentials_jq_error(provider={provider},profile={profile})")
155 })?;
156
157 if out.is_empty() {
158 return Err(format!(
159 "auth_credentials_missing(provider={provider},profile={profile})"
160 ));
161 }
162 if out.len() != 1 {
163 return Err(format!(
164 "auth_credentials_ambiguous(provider={provider},profile={profile},count={})",
165 out.len()
166 ));
167 }
168
169 let v = out.into_iter().next().unwrap_or(serde_json::Value::Null);
170 match v {
171 serde_json::Value::Object(_) => Ok(v),
172 serde_json::Value::Null => Err(format!(
173 "auth_credentials_missing(provider={provider},profile={profile})"
174 )),
175 _ => Err(format!(
176 "auth_credentials_invalid(provider={provider},profile={profile})"
177 )),
178 }
179 }
180
181 fn extract_token(
182 &self,
183 response_json: &serde_json::Value,
184 token_expr: &str,
185 provider: &str,
186 profile: &str,
187 ) -> std::result::Result<String, String> {
188 let token = crate::jq::query_raw(response_json, token_expr)
189 .map_err(|_| format!("auth_token_jq_error(provider={provider},profile={profile})"))?
190 .into_iter()
191 .next()
192 .unwrap_or_default();
193
194 let token = token.trim().to_string();
195 if token.is_empty() || token == "null" {
196 return Err(format!(
197 "auth_token_missing(provider={provider},profile={profile})"
198 ));
199 }
200 Ok(token)
201 }
202
203 fn login_rest(
204 &self,
205 profile: &str,
206 repo_root: &Path,
207 suite_defaults: &SuiteDefaults,
208 env_rest_url: &str,
209 ) -> std::result::Result<String, String> {
210 let Some(rest) = &self.auth.rest else {
211 return Err(String::new());
212 };
213
214 let provider = "rest";
215 let creds = self.render_credentials(profile, &rest.credentials_jq, provider)?;
216
217 let template_path = resolve_path_from_repo_root(repo_root, &rest.login_request_template);
218 if !template_path.is_file() {
219 return Err(format!(
220 "auth_login_template_render_failed(provider={provider},profile={profile})"
221 ));
222 }
223
224 let raw: serde_json::Value =
225 serde_json::from_slice(&std::fs::read(&template_path).map_err(|_| {
226 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
227 })?)
228 .map_err(|_| {
229 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
230 })?;
231
232 let mut obj = raw.as_object().cloned().ok_or_else(|| {
233 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
234 })?;
235
236 let body = obj
237 .get("body")
238 .cloned()
239 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
240 let mut body_obj = match body {
241 serde_json::Value::Null => serde_json::Map::new(),
242 serde_json::Value::Object(m) => m,
243 _ => {
244 return Err(format!(
245 "auth_login_template_render_failed(provider={provider},profile={profile})"
246 ));
247 }
248 };
249
250 let serde_json::Value::Object(creds_obj) = creds else {
251 return Err(format!(
252 "auth_login_template_render_failed(provider={provider},profile={profile})"
253 ));
254 };
255 for (k, v) in creds_obj {
256 body_obj.insert(k, v);
257 }
258 obj.insert("body".to_string(), serde_json::Value::Object(body_obj));
259
260 let request = crate::rest::schema::parse_rest_request_json(serde_json::Value::Object(obj))
261 .map_err(|_| {
262 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
263 })?;
264
265 let request_file = crate::rest::schema::RestRequestFile {
266 path: template_path.clone(),
267 request,
268 };
269
270 let base_url = resolve_auth_rest_base_url(
271 repo_root,
272 &rest.config_dir,
273 &rest.url,
274 &rest.env,
275 suite_defaults,
276 env_rest_url,
277 )
278 .map_err(|_| {
279 format!("auth_login_request_failed(provider={provider},profile={profile},rc=1)")
280 })?;
281
282 let executed = crate::rest::runner::execute_rest_request(&request_file, &base_url, None)
283 .map_err(|_| {
284 format!("auth_login_request_failed(provider={provider},profile={profile},rc=1)")
285 })?;
286 crate::rest::expect::evaluate_main_response(&request_file.request, &executed).map_err(
287 |_| format!("auth_login_request_failed(provider={provider},profile={profile},rc=1)"),
288 )?;
289
290 let response_json: serde_json::Value = serde_json::from_slice(&executed.response.body)
291 .map_err(|_| format!("auth_token_jq_error(provider={provider},profile={profile})"))?;
292
293 self.extract_token(&response_json, &rest.token_jq, provider, profile)
294 }
295
296 fn login_graphql(
297 &self,
298 profile: &str,
299 repo_root: &Path,
300 suite_defaults: &SuiteDefaults,
301 env_gql_url: &str,
302 ) -> std::result::Result<String, String> {
303 let Some(gql) = &self.auth.graphql else {
304 return Err(String::new());
305 };
306
307 let provider = "graphql";
308 let creds = self.render_credentials(profile, &gql.credentials_jq, provider)?;
309
310 let op_path = resolve_path_from_repo_root(repo_root, &gql.login_op);
311 if !op_path.is_file() {
312 return Err(format!(
313 "auth_login_template_render_failed(provider={provider},profile={profile})"
314 ));
315 }
316 let vars_template_path = resolve_path_from_repo_root(repo_root, &gql.login_vars_template);
317 if !vars_template_path.is_file() {
318 return Err(format!(
319 "auth_login_template_render_failed(provider={provider},profile={profile})"
320 ));
321 }
322
323 let vars_template: serde_json::Value =
324 serde_json::from_slice(&std::fs::read(&vars_template_path).map_err(|_| {
325 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
326 })?)
327 .map_err(|_| {
328 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
329 })?;
330
331 let mut vars_obj = match vars_template {
332 serde_json::Value::Object(m) => m,
333 _ => {
334 return Err(format!(
335 "auth_login_template_render_failed(provider={provider},profile={profile})"
336 ));
337 }
338 };
339
340 let serde_json::Value::Object(creds_obj) = creds else {
341 return Err(format!(
342 "auth_login_template_render_failed(provider={provider},profile={profile})"
343 ));
344 };
345 for (k, v) in creds_obj {
346 vars_obj.insert(k, v);
347 }
348 let vars_json = serde_json::Value::Object(vars_obj);
349
350 let endpoint_url = resolve_auth_gql_url(
351 repo_root,
352 &gql.config_dir,
353 &gql.url,
354 &gql.env,
355 suite_defaults,
356 env_gql_url,
357 )
358 .map_err(|_| {
359 format!("auth_login_request_failed(provider={provider},profile={profile},rc=1)")
360 })?;
361
362 let op_file =
363 crate::graphql::schema::GraphqlOperationFile::load(&op_path).map_err(|_| {
364 format!("auth_login_template_render_failed(provider={provider},profile={profile})")
365 })?;
366
367 let executed = crate::graphql::runner::execute_graphql_request(
368 &endpoint_url,
369 None,
370 &op_file.operation,
371 Some(&vars_json),
372 )
373 .map_err(|_| {
374 format!("auth_login_request_failed(provider={provider},profile={profile},rc=1)")
375 })?;
376
377 let response_json: serde_json::Value = serde_json::from_slice(&executed.response.body)
378 .map_err(|_| format!("auth_token_jq_error(provider={provider},profile={profile})"))?;
379
380 self.extract_token(&response_json, &gql.token_jq, provider, profile)
381 }
382}
383
384fn canonical_provider(auth: &SuiteAuth) -> Result<AuthProvider> {
385 let provider_raw = auth.provider.trim().to_ascii_lowercase();
386 let provider = if provider_raw.is_empty() {
387 match (&auth.rest, &auth.graphql) {
388 (Some(_), None) => "rest".to_string(),
389 (None, Some(_)) => "graphql".to_string(),
390 (Some(_), Some(_)) => anyhow::bail!(
391 "Invalid suite auth block: .auth.provider is required when both .auth.rest and .auth.graphql are present"
392 ),
393 (None, None) => {
394 anyhow::bail!("Invalid suite auth block: missing auth.rest/auth.graphql")
395 }
396 }
397 } else if provider_raw == "gql" {
398 "graphql".to_string()
399 } else {
400 provider_raw
401 };
402
403 match provider.as_str() {
404 "rest" => Ok(AuthProvider::Rest),
405 "graphql" => Ok(AuthProvider::Graphql),
406 _ => {
407 anyhow::bail!("Invalid suite auth block: .auth.provider must be one of: rest, graphql")
408 }
409 }
410}
411
412fn inherit_auth_defaults(mut auth: SuiteAuth, suite_defaults: &SuiteDefaults) -> SuiteAuth {
413 if let Some(rest) = auth.rest.as_mut()
414 && rest.config_dir.trim().is_empty()
415 {
416 rest.config_dir = suite_defaults.rest.config_dir.clone();
417 }
418 if let Some(gql) = auth.graphql.as_mut()
419 && gql.config_dir.trim().is_empty()
420 {
421 gql.config_dir = suite_defaults.graphql.config_dir.clone();
422 }
423 auth
424}
425
426fn resolve_auth_rest_base_url(
427 repo_root: &Path,
428 config_dir: &str,
429 url_override: &str,
430 env_override: &str,
431 suite_defaults: &SuiteDefaults,
432 env_rest_url: &str,
433) -> Result<String> {
434 let url_override = url_override.trim();
435 if !url_override.is_empty() {
436 return Ok(url_override.to_string());
437 }
438 let default_url = suite_defaults.rest.url.trim();
439 if !default_url.is_empty() {
440 return Ok(default_url.to_string());
441 }
442 let env_rest_url = env_rest_url.trim();
443 if !env_rest_url.is_empty() {
444 return Ok(env_rest_url.to_string());
445 }
446
447 let env_value = if !env_override.trim().is_empty() {
448 env_override.trim()
449 } else {
450 suite_defaults.env.trim()
451 };
452 if env_value.is_empty() {
453 anyhow::bail!("auth missing rest env/url");
454 }
455
456 let setup_dir = resolve_path_from_repo_root(repo_root, config_dir);
457 resolve_rest_base_url_for_env(&setup_dir, env_value)
458}
459
460fn resolve_auth_gql_url(
461 repo_root: &Path,
462 config_dir: &str,
463 url_override: &str,
464 env_override: &str,
465 suite_defaults: &SuiteDefaults,
466 env_gql_url: &str,
467) -> Result<String> {
468 let url_override = url_override.trim();
469 if !url_override.is_empty() {
470 return Ok(url_override.to_string());
471 }
472 let default_url = suite_defaults.graphql.url.trim();
473 if !default_url.is_empty() {
474 return Ok(default_url.to_string());
475 }
476 let env_gql_url = env_gql_url.trim();
477 if !env_gql_url.is_empty() {
478 return Ok(env_gql_url.to_string());
479 }
480
481 let env_value = if !env_override.trim().is_empty() {
482 env_override.trim()
483 } else {
484 suite_defaults.env.trim()
485 };
486 if env_value.is_empty() {
487 anyhow::bail!("auth missing graphql env/url");
488 }
489
490 let setup_dir = resolve_path_from_repo_root(repo_root, config_dir);
491 resolve_gql_url_for_env(&setup_dir, env_value)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 use nils_test_support::{EnvGuard, GlobalStateLock};
499 use pretty_assertions::assert_eq;
500 use tempfile::TempDir;
501
502 #[test]
503 fn suite_auth_credentials_jq_requires_exactly_one_object() {
504 let auth = SuiteAuth {
505 provider: "rest".to_string(),
506 required: true,
507 secret_env: "API_TEST_AUTH_JSON".to_string(),
508 rest: Some(crate::suite::schema::SuiteAuthRest {
509 login_request_template: "setup/rest/requests/login.request.json".to_string(),
510 credentials_jq: ".profiles[$profile]".to_string(),
511 token_jq: ".accessToken".to_string(),
512 config_dir: "setup/rest".to_string(),
513 url: "http://localhost:0".to_string(),
514 env: String::new(),
515 }),
516 graphql: None,
517 };
518
519 let mgr = SuiteAuthManager {
521 provider: AuthProvider::Rest,
522 provider_label: "rest".to_string(),
523 secret_json: serde_json::json!({"profiles": {"admin": {"u": "a"}}}),
524 auth,
525 tokens: HashMap::new(),
526 errors: HashMap::new(),
527 };
528
529 let creds = mgr
530 .render_credentials("admin", ".profiles[$profile]", "rest")
531 .unwrap();
532 assert!(creds.is_object());
533
534 let err = mgr
535 .render_credentials("missing", ".profiles[$profile]", "rest")
536 .unwrap_err();
537 assert!(err.contains("auth_credentials_missing"));
538 }
539
540 fn auth_rest_stub() -> crate::suite::schema::SuiteAuthRest {
541 crate::suite::schema::SuiteAuthRest {
542 login_request_template: "setup/rest/requests/login.request.json".to_string(),
543 credentials_jq: ".profiles[$profile]".to_string(),
544 token_jq: ".accessToken".to_string(),
545 config_dir: "setup/rest".to_string(),
546 url: String::new(),
547 env: String::new(),
548 }
549 }
550
551 fn auth_graphql_stub() -> crate::suite::schema::SuiteAuthGraphql {
552 crate::suite::schema::SuiteAuthGraphql {
553 login_op: "setup/graphql/operations/login.graphql".to_string(),
554 login_vars_template: "setup/graphql/vars/login.json".to_string(),
555 credentials_jq: ".profiles[$profile]".to_string(),
556 token_jq: ".token".to_string(),
557 config_dir: "setup/graphql".to_string(),
558 url: String::new(),
559 env: String::new(),
560 }
561 }
562
563 #[test]
564 fn canonical_provider_infers_rest_when_only_rest_present() {
565 let auth = SuiteAuth {
566 provider: String::new(),
567 required: true,
568 secret_env: "API_TEST_AUTH_JSON".to_string(),
569 rest: Some(auth_rest_stub()),
570 graphql: None,
571 };
572
573 let provider = canonical_provider(&auth).expect("provider");
574 assert_eq!(provider, AuthProvider::Rest);
575 }
576
577 #[test]
578 fn canonical_provider_infers_graphql_when_only_graphql_present() {
579 let auth = SuiteAuth {
580 provider: String::new(),
581 required: true,
582 secret_env: "API_TEST_AUTH_JSON".to_string(),
583 rest: None,
584 graphql: Some(auth_graphql_stub()),
585 };
586
587 let provider = canonical_provider(&auth).expect("provider");
588 assert_eq!(provider, AuthProvider::Graphql);
589 }
590
591 #[test]
592 fn canonical_provider_supports_gql_alias() {
593 let auth = SuiteAuth {
594 provider: "gql".to_string(),
595 required: true,
596 secret_env: "API_TEST_AUTH_JSON".to_string(),
597 rest: None,
598 graphql: Some(auth_graphql_stub()),
599 };
600
601 let provider = canonical_provider(&auth).expect("provider");
602 assert_eq!(provider, AuthProvider::Graphql);
603 }
604
605 #[test]
606 fn canonical_provider_requires_provider_when_both_present() {
607 let auth = SuiteAuth {
608 provider: String::new(),
609 required: true,
610 secret_env: "API_TEST_AUTH_JSON".to_string(),
611 rest: Some(auth_rest_stub()),
612 graphql: Some(auth_graphql_stub()),
613 };
614
615 let err = canonical_provider(&auth).unwrap_err().to_string();
616 assert!(err.contains(
617 ".auth.provider is required when both .auth.rest and .auth.graphql are present"
618 ));
619 }
620
621 #[test]
622 fn canonical_provider_rejects_unknown_provider() {
623 let auth = SuiteAuth {
624 provider: "nope".to_string(),
625 required: true,
626 secret_env: "API_TEST_AUTH_JSON".to_string(),
627 rest: Some(auth_rest_stub()),
628 graphql: None,
629 };
630
631 let err = canonical_provider(&auth).unwrap_err().to_string();
632 assert!(err.contains(".auth.provider must be one of: rest, graphql"));
633 }
634
635 #[test]
636 fn init_from_suite_missing_secret_env_required_false_disables_auth() {
637 let lock = GlobalStateLock::new();
638 let key = "NILS_TEST_AUTH_JSON_MISSING";
639 let _guard = EnvGuard::remove(&lock, key);
640
641 let auth = SuiteAuth {
642 provider: String::new(),
643 required: false,
644 secret_env: key.to_string(),
645 rest: Some(auth_rest_stub()),
646 graphql: None,
647 };
648 let defaults = SuiteDefaults::default();
649
650 let init = SuiteAuthManager::init_from_suite(auth, &defaults).expect("init");
651 let AuthInit::Disabled { message } = init else {
652 panic!("expected disabled");
653 };
654 let msg = message.unwrap_or_default();
655 assert!(msg.contains("auth disabled"));
656 assert!(msg.contains(key));
657 assert!(msg.contains("auth.required=false"));
658 }
659
660 #[test]
661 fn init_from_suite_missing_secret_env_required_true_is_error() {
662 let lock = GlobalStateLock::new();
663 let key = "NILS_TEST_AUTH_JSON_REQUIRED";
664 let _guard = EnvGuard::remove(&lock, key);
665
666 let auth = SuiteAuth {
667 provider: "rest".to_string(),
668 required: true,
669 secret_env: key.to_string(),
670 rest: Some(auth_rest_stub()),
671 graphql: None,
672 };
673 let defaults = SuiteDefaults::default();
674
675 let err = SuiteAuthManager::init_from_suite(auth, &defaults)
676 .unwrap_err()
677 .to_string();
678 assert!(err.contains("Missing auth secret env var for suite auth"));
679 assert!(err.contains(key));
680 }
681
682 #[test]
683 fn init_from_suite_invalid_json_is_error() {
684 let lock = GlobalStateLock::new();
685 let key = "NILS_TEST_AUTH_JSON_INVALID";
686 let _guard = EnvGuard::set(&lock, key, "{");
687
688 let auth = SuiteAuth {
689 provider: "rest".to_string(),
690 required: true,
691 secret_env: key.to_string(),
692 rest: Some(auth_rest_stub()),
693 graphql: None,
694 };
695 let defaults = SuiteDefaults::default();
696
697 let err = SuiteAuthManager::init_from_suite(auth, &defaults)
698 .unwrap_err()
699 .to_string();
700 assert!(err.contains(&format!("Invalid JSON in {key}")));
701 }
702
703 fn stub_mgr(provider: AuthProvider, provider_label: &str) -> SuiteAuthManager {
704 SuiteAuthManager {
705 provider,
706 provider_label: provider_label.to_string(),
707 secret_json: serde_json::Value::Object(serde_json::Map::new()),
708 auth: SuiteAuth {
709 provider: provider_label.to_string(),
710 required: true,
711 secret_env: "API_TEST_AUTH_JSON".to_string(),
712 rest: None,
713 graphql: None,
714 },
715 tokens: HashMap::new(),
716 errors: HashMap::new(),
717 }
718 }
719
720 #[test]
721 fn ensure_token_caches_successful_login_token() {
722 use std::cell::Cell;
723
724 let calls = Cell::new(0);
725 let mut mgr = stub_mgr(AuthProvider::Rest, "rest");
726
727 let t1 = mgr
728 .ensure_token_with_login("admin", |_mgr, profile| {
729 calls.set(calls.get() + 1);
730 Ok(format!("tok-{profile}"))
731 })
732 .expect("token");
733 assert_eq!(t1, "tok-admin");
734
735 let t2 = mgr
736 .ensure_token_with_login("admin", |_mgr, _profile| {
737 calls.set(calls.get() + 1);
738 Ok("tok-should-not-be-called".to_string())
739 })
740 .expect("token");
741 assert_eq!(t2, "tok-admin");
742 assert_eq!(calls.get(), 1);
743 }
744
745 #[test]
746 fn ensure_token_memoizes_errors_and_does_not_retry() {
747 use std::cell::Cell;
748
749 let calls = Cell::new(0);
750 let mut mgr = stub_mgr(AuthProvider::Graphql, "graphql");
751
752 let err1 = mgr
753 .ensure_token_with_login("svc", |_mgr, _profile| {
754 calls.set(calls.get() + 1);
755 Err(String::new())
756 })
757 .unwrap_err();
758 assert_eq!(err1, "auth_login_failed(provider=graphql,profile=svc)");
759
760 let err2 = mgr
761 .ensure_token_with_login("svc", |_mgr, _profile| {
762 calls.set(calls.get() + 1);
763 Ok("tok-should-not-be-called".to_string())
764 })
765 .unwrap_err();
766 assert_eq!(err2, "auth_login_failed(provider=graphql,profile=svc)");
767 assert_eq!(calls.get(), 1);
768 }
769
770 #[test]
771 fn resolve_auth_rest_base_url_precedence_and_env_lookup() {
772 let tmp = TempDir::new().expect("tempdir");
773 let repo_root = tmp.path();
774
775 let mut defaults = SuiteDefaults {
776 env: "staging".to_string(),
777 rest: crate::suite::schema::SuiteDefaultsRest {
778 url: "http://default.example".to_string(),
779 ..Default::default()
780 },
781 ..Default::default()
782 };
783
784 let got = resolve_auth_rest_base_url(
785 repo_root,
786 "setup/rest",
787 "http://override.example",
788 "",
789 &defaults,
790 "http://env.example",
791 )
792 .expect("url");
793 assert_eq!(got, "http://override.example");
794
795 let got = resolve_auth_rest_base_url(
796 repo_root,
797 "setup/rest",
798 "",
799 "",
800 &defaults,
801 "http://env.example",
802 )
803 .expect("url");
804 assert_eq!(got, "http://default.example");
805
806 defaults.rest.url = String::new();
807 let got = resolve_auth_rest_base_url(
808 repo_root,
809 "setup/rest",
810 "",
811 "",
812 &defaults,
813 "http://env.example",
814 )
815 .expect("url");
816 assert_eq!(got, "http://env.example");
817
818 let setup_dir = repo_root.join("setup/rest");
819 std::fs::create_dir_all(&setup_dir).expect("mkdir");
820 std::fs::write(
821 setup_dir.join("endpoints.env"),
822 "REST_URL_STAGING=http://staging.example\nREST_URL_PROD=http://prod.example\n",
823 )
824 .expect("write endpoints.env");
825
826 let got = resolve_auth_rest_base_url(repo_root, "setup/rest", "", "", &defaults, "")
827 .expect("url");
828 assert_eq!(got, "http://staging.example");
829
830 let got = resolve_auth_rest_base_url(repo_root, "setup/rest", "", "prod", &defaults, "")
831 .expect("url");
832 assert_eq!(got, "http://prod.example");
833 }
834
835 #[test]
836 fn resolve_auth_gql_url_precedence_and_env_lookup() {
837 let tmp = TempDir::new().expect("tempdir");
838 let repo_root = tmp.path();
839
840 let mut defaults = SuiteDefaults {
841 env: "staging".to_string(),
842 graphql: crate::suite::schema::SuiteDefaultsGraphql {
843 url: "http://default.example/graphql".to_string(),
844 ..Default::default()
845 },
846 ..Default::default()
847 };
848
849 let got = resolve_auth_gql_url(
850 repo_root,
851 "setup/graphql",
852 "http://override.example/graphql",
853 "",
854 &defaults,
855 "http://env.example/graphql",
856 )
857 .expect("url");
858 assert_eq!(got, "http://override.example/graphql");
859
860 let got = resolve_auth_gql_url(
861 repo_root,
862 "setup/graphql",
863 "",
864 "",
865 &defaults,
866 "http://env.example/graphql",
867 )
868 .expect("url");
869 assert_eq!(got, "http://default.example/graphql");
870
871 defaults.graphql.url = String::new();
872 let got = resolve_auth_gql_url(
873 repo_root,
874 "setup/graphql",
875 "",
876 "",
877 &defaults,
878 "http://env.example/graphql",
879 )
880 .expect("url");
881 assert_eq!(got, "http://env.example/graphql");
882
883 let setup_dir = repo_root.join("setup/graphql");
884 std::fs::create_dir_all(&setup_dir).expect("mkdir");
885 std::fs::write(
886 setup_dir.join("endpoints.env"),
887 "GQL_URL_STAGING=http://staging.example/graphql\nGQL_URL_PROD=http://prod.example/graphql\n",
888 )
889 .expect("write endpoints.env");
890
891 let got =
892 resolve_auth_gql_url(repo_root, "setup/graphql", "", "", &defaults, "").expect("url");
893 assert_eq!(got, "http://staging.example/graphql");
894
895 let got = resolve_auth_gql_url(repo_root, "setup/graphql", "", "prod", &defaults, "")
896 .expect("url");
897 assert_eq!(got, "http://prod.example/graphql");
898 }
899}