Skip to main content

api_testing_core/suite/
auth.rs

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        // Inherit auth configDir from suite defaults when omitted (parity with api-test.sh).
68        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        // Note: we are testing render_credentials directly; avoid env reads.
520        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}