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