Skip to main content

api_testing_core/
auth_env.rs

1use std::path::Path;
2
3use crate::{Result, cli_util, env_file};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum ProfileTokenSource {
7    None,
8    Profile,
9    EnvFallback { env_name: String },
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ProfileTokenResolution {
14    pub bearer_token: Option<String>,
15    pub token_name: String,
16    pub source: ProfileTokenSource,
17}
18
19#[derive(Debug, Clone, Copy)]
20pub struct ProfileTokenConfig<'a> {
21    pub token_name_arg: Option<&'a str>,
22    pub token_name_env_var: &'a str,
23    pub token_name_file_var: &'a str,
24    pub token_var_prefix: &'a str,
25    pub tokens_env: &'a Path,
26    pub tokens_local: &'a Path,
27    pub tokens_files: &'a [&'a Path],
28    pub missing_profile_hint: &'a str,
29    pub env_fallback_keys: &'a [&'a str],
30}
31
32pub fn resolve_profile_or_env_fallback(
33    config: ProfileTokenConfig<'_>,
34) -> Result<ProfileTokenResolution> {
35    let token_name_arg = config.token_name_arg.and_then(cli_util::trim_non_empty);
36    let token_name_env = std::env::var(config.token_name_env_var)
37        .ok()
38        .and_then(|s| cli_util::trim_non_empty(&s));
39    let token_name_file = if !config.tokens_files.is_empty() {
40        env_file::read_var_last_wins(config.token_name_file_var, config.tokens_files)?
41    } else {
42        None
43    };
44
45    let token_profile_selected =
46        token_name_arg.is_some() || token_name_env.is_some() || token_name_file.is_some();
47    let token_name = token_name_arg
48        .or(token_name_env)
49        .or(token_name_file)
50        .unwrap_or_else(|| "default".to_string())
51        .to_ascii_lowercase();
52
53    if token_profile_selected {
54        let token_key = cli_util::to_env_key(&token_name);
55        let token_var = format!("{}{}", config.token_var_prefix, token_key);
56        let bearer_token = env_file::read_var_last_wins(&token_var, config.tokens_files)?;
57        let Some(bearer_token) = bearer_token else {
58            let available = available_token_profiles(
59                config.tokens_env,
60                config.tokens_local,
61                config.token_var_prefix,
62            );
63            anyhow::bail!(
64                "Token profile '{token_name}' is empty/missing (available: {available}). {}",
65                config.missing_profile_hint
66            );
67        };
68
69        return Ok(ProfileTokenResolution {
70            bearer_token: Some(bearer_token),
71            token_name,
72            source: ProfileTokenSource::Profile,
73        });
74    }
75
76    if let Some((token, env_name)) = resolve_env_fallback(config.env_fallback_keys) {
77        return Ok(ProfileTokenResolution {
78            bearer_token: Some(token),
79            token_name,
80            source: ProfileTokenSource::EnvFallback { env_name },
81        });
82    }
83
84    Ok(ProfileTokenResolution {
85        bearer_token: None,
86        token_name,
87        source: ProfileTokenSource::None,
88    })
89}
90
91fn available_token_profiles(
92    tokens_env: &Path,
93    tokens_local: &Path,
94    token_var_prefix: &str,
95) -> String {
96    let mut available = cli_util::list_available_suffixes(tokens_env, token_var_prefix);
97    if tokens_local.is_file() {
98        available.extend(cli_util::list_available_suffixes(
99            tokens_local,
100            token_var_prefix,
101        ));
102        available.sort();
103        available.dedup();
104    }
105    available.retain(|name| name != "name");
106    if available.is_empty() {
107        "none".to_string()
108    } else {
109        available.join(" ")
110    }
111}
112
113pub fn resolve_env_fallback(keys: &[&str]) -> Option<(String, String)> {
114    for &key in keys {
115        let Ok(value) = std::env::var(key) else {
116            continue;
117        };
118        if let Some(token) = cli_util::trim_non_empty(&value) {
119            return Some((token, key.to_string()));
120        }
121    }
122    None
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use nils_test_support::{EnvGuard, GlobalStateLock};
129    use tempfile::TempDir;
130
131    fn write_file(path: &Path, contents: &str) {
132        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
133        std::fs::write(path, contents).expect("write");
134    }
135
136    #[test]
137    fn resolve_env_fallback_prefers_order() {
138        let lock = GlobalStateLock::new();
139        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "access");
140        let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
141
142        assert_eq!(
143            resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
144            Some(("access".to_string(), "ACCESS_TOKEN".to_string()))
145        );
146    }
147
148    #[test]
149    fn resolve_env_fallback_skips_empty_and_whitespace() {
150        let lock = GlobalStateLock::new();
151        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "  ");
152        let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
153
154        assert_eq!(
155            resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
156            Some(("service".to_string(), "SERVICE_TOKEN".to_string()))
157        );
158    }
159
160    #[test]
161    fn resolve_env_fallback_returns_none_when_missing() {
162        let lock = GlobalStateLock::new();
163        let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
164        let _service = EnvGuard::remove(&lock, "SERVICE_TOKEN");
165
166        assert_eq!(
167            resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
168            None
169        );
170    }
171
172    #[test]
173    fn resolve_profile_or_env_fallback_prefers_selected_profile() {
174        let lock = GlobalStateLock::new();
175        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
176        let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
177
178        let tmp = TempDir::new().expect("tmp");
179        let tokens_env = tmp.path().join("tokens.env");
180        let tokens_local = tmp.path().join("tokens.local.env");
181        write_file(&tokens_env, "REST_TOKEN_SVC=svc-token\n");
182
183        let files = [&tokens_env as &Path, &tokens_local as &Path];
184        let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
185            token_name_arg: Some("svc"),
186            token_name_env_var: "REST_TOKEN_NAME",
187            token_name_file_var: "REST_TOKEN_NAME",
188            token_var_prefix: "REST_TOKEN_",
189            tokens_env: &tokens_env,
190            tokens_local: &tokens_local,
191            tokens_files: &files,
192            missing_profile_hint: "hint",
193            env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
194        })
195        .expect("profile token resolution");
196
197        assert_eq!(resolved.bearer_token.as_deref(), Some("svc-token"));
198        assert_eq!(resolved.token_name, "svc");
199        assert_eq!(resolved.source, ProfileTokenSource::Profile);
200    }
201
202    #[test]
203    fn resolve_profile_or_env_fallback_uses_env_fallback_without_profile_selection() {
204        let lock = GlobalStateLock::new();
205        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
206        let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
207
208        let tmp = TempDir::new().expect("tmp");
209        let tokens_env = tmp.path().join("tokens.env");
210        let tokens_local = tmp.path().join("tokens.local.env");
211        let files = [&tokens_env as &Path, &tokens_local as &Path];
212
213        let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
214            token_name_arg: None,
215            token_name_env_var: "REST_TOKEN_NAME",
216            token_name_file_var: "REST_TOKEN_NAME",
217            token_var_prefix: "REST_TOKEN_",
218            tokens_env: &tokens_env,
219            tokens_local: &tokens_local,
220            tokens_files: &files,
221            missing_profile_hint: "hint",
222            env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
223        })
224        .expect("fallback resolution");
225
226        assert_eq!(resolved.bearer_token.as_deref(), Some("env-token"));
227        assert_eq!(resolved.token_name, "default");
228        assert_eq!(
229            resolved.source,
230            ProfileTokenSource::EnvFallback {
231                env_name: "ACCESS_TOKEN".to_string()
232            }
233        );
234    }
235
236    #[test]
237    fn resolve_profile_or_env_fallback_reports_available_profiles_when_missing() {
238        let lock = GlobalStateLock::new();
239        let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "missing");
240        let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
241
242        let tmp = TempDir::new().expect("tmp");
243        let tokens_env = tmp.path().join("tokens.env");
244        let tokens_local = tmp.path().join("tokens.local.env");
245        write_file(
246            &tokens_env,
247            "REST_TOKEN_SVC=svc-token\nREST_TOKEN_DEV=dev-token\n",
248        );
249        let files = [&tokens_env as &Path, &tokens_local as &Path];
250
251        let err = resolve_profile_or_env_fallback(ProfileTokenConfig {
252            token_name_arg: None,
253            token_name_env_var: "REST_TOKEN_NAME",
254            token_name_file_var: "REST_TOKEN_NAME",
255            token_var_prefix: "REST_TOKEN_",
256            tokens_env: &tokens_env,
257            tokens_local: &tokens_local,
258            tokens_files: &files,
259            missing_profile_hint: "Set it in setup/rest/tokens.local.env.",
260            env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
261        })
262        .expect_err("missing profile should error");
263
264        let text = err.to_string();
265        assert!(text.contains("Token profile 'missing' is empty/missing"));
266        assert!(text.contains("svc dev") || text.contains("dev svc"));
267        assert!(text.contains("setup/rest/tokens.local.env"));
268    }
269
270    #[test]
271    fn resolve_profile_or_env_fallback_prefers_env_over_file_for_name() {
272        let lock = GlobalStateLock::new();
273        let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "prod");
274
275        let tmp = TempDir::new().expect("tmp");
276        let tokens_env = tmp.path().join("tokens.env");
277        let tokens_local = tmp.path().join("tokens.local.env");
278        write_file(
279            &tokens_env,
280            "REST_TOKEN_NAME=staging\nREST_TOKEN_PROD=prod-token\n",
281        );
282        let files = [&tokens_env as &Path, &tokens_local as &Path];
283
284        let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
285            token_name_arg: None,
286            token_name_env_var: "REST_TOKEN_NAME",
287            token_name_file_var: "REST_TOKEN_NAME",
288            token_var_prefix: "REST_TOKEN_",
289            tokens_env: &tokens_env,
290            tokens_local: &tokens_local,
291            tokens_files: &files,
292            missing_profile_hint: "hint",
293            env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
294        })
295        .expect("env token name resolution");
296
297        assert_eq!(resolved.bearer_token.as_deref(), Some("prod-token"));
298        assert_eq!(resolved.token_name, "prod");
299        assert_eq!(resolved.source, ProfileTokenSource::Profile);
300    }
301}