Skip to main content

omni_dev/atlassian/
auth.rs

1//! Atlassian credential management.
2//!
3//! Loads and saves Atlassian Basic and Bearer credentials from/to the
4//! `~/.omni-dev/settings.json` file — the active profile's `env` map when a
5//! profile is selected, the base `env` map otherwise (issue #1116).
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::atlassian::error::AtlassianError;
11use crate::utils::env::{EnvSource, SystemEnv};
12use crate::utils::secret::Secret;
13use crate::utils::secret_env::{secret_var, secret_var_is_set};
14use crate::utils::settings::{active_profile_from, Settings};
15
16/// Environment variable / settings key for the Atlassian instance URL.
17pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
18
19/// Environment variable / settings key for the Atlassian user email.
20pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
21
22/// Environment variable / settings key for the Atlassian API token.
23pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
24
25/// Environment variable that overrides the Atlassian instance URL.
26///
27/// Applies to **every** JIRA/Confluence command. Set by the `--instance` flag
28/// via [`crate::cli::atlassian::InstanceArg::apply`]; takes precedence
29/// over `ATLASSIAN_INSTANCE_URL` / settings.json. A caller-supplied override
30/// (e.g. via [`load_credentials_with_instance`]) still wins over it.
31pub const ATLASSIAN_INSTANCE_OVERRIDE_ENV: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
32
33/// Environment variable / settings key for a Server/Data Center PAT.
34pub const ATLASSIAN_PAT: &str = "ATLASSIAN_PAT";
35
36/// Authentication credentials; secret values are redacted in Debug output.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum AtlassianAuth {
39    /// Cloud Basic authentication.
40    Basic {
41        /// Account email.
42        email: String,
43        /// Cloud API token.
44        api_token: Secret,
45    },
46    /// Server/Data Center Personal Access Token authentication.
47    Bearer {
48        /// Personal Access Token.
49        token: Secret,
50    },
51}
52
53/// Non-secret authentication mode.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum AuthMode {
57    /// Email and API token.
58    #[default]
59    Basic,
60    /// Personal Access Token without email.
61    Bearer,
62}
63
64impl AtlassianAuth {
65    /// Returns the authentication mode without exposing credentials.
66    #[must_use]
67    pub const fn mode(&self) -> AuthMode {
68        match self {
69            Self::Basic { .. } => AuthMode::Basic,
70            Self::Bearer { .. } => AuthMode::Bearer,
71        }
72    }
73
74    /// Validates required fields without printing their contents.
75    pub(crate) fn validate(&self) -> Result<()> {
76        let token = match self {
77            Self::Basic { email, api_token } => {
78                if email.trim().is_empty() {
79                    anyhow::bail!("ATLASSIAN_EMAIL is required for Basic authentication");
80                }
81                api_token.expose_secret()
82            }
83            Self::Bearer { token } => token.expose_secret(),
84        };
85        if token.trim().is_empty() {
86            anyhow::bail!("Atlassian token must not be blank");
87        }
88        if token.chars().any(char::is_control) {
89            anyhow::bail!("Atlassian token contains invalid control characters");
90        }
91        Ok(())
92    }
93}
94
95/// Atlassian credentials for one instance.
96#[derive(Debug, Clone)]
97pub struct AtlassianCredentials {
98    /// Base URL, including any self-hosted context path.
99    pub instance_url: String,
100    /// Selected authentication credentials.
101    pub auth: AtlassianAuth,
102}
103
104/// Loads Atlassian credentials from environment variables or settings.json.
105///
106/// Checks environment variables first, then falls back to the settings file.
107/// The `--instance` flag (propagated to [`ATLASSIAN_INSTANCE_OVERRIDE_ENV`])
108/// overrides the configured instance URL for every command; a blank value is
109/// ignored. Callers that carry their own explicit override should call
110/// [`load_credentials_with_instance`] directly.
111pub fn load_credentials() -> Result<AtlassianCredentials> {
112    let env_override = std::env::var(ATLASSIAN_INSTANCE_OVERRIDE_ENV)
113        .ok()
114        .filter(|s| !s.trim().is_empty());
115    load_credentials_with_instance(env_override.as_deref())
116}
117
118/// Loads Atlassian credentials, optionally overriding the instance URL.
119///
120/// When `instance_override` is `Some`, that URL is used verbatim (after
121/// trailing-slash normalization) and the `ATLASSIAN_INSTANCE_URL` env /
122/// settings lookup is skipped — so a caller-supplied instance (e.g.
123/// `jira create --instance`) works even when no instance is configured in the
124/// environment. Authentication requires either email/API token or a PAT.
125/// When `None`, behaves exactly like [`load_credentials`].
126pub fn load_credentials_with_instance(
127    instance_override: Option<&str>,
128) -> Result<AtlassianCredentials> {
129    let settings = Settings::load_or_warn_default();
130
131    load_credentials_from(&settings.env_source(), instance_override)
132}
133
134/// Resolves credentials through an injected layered environment source.
135pub(crate) fn load_credentials_from(
136    env: &impl EnvSource,
137    instance_override: Option<&str>,
138) -> Result<AtlassianCredentials> {
139    let scope = scope_status_from(env);
140    if scope.configuration_state == ConfigurationState::Conflict {
141        anyhow::bail!(
142            "ATLASSIAN_PAT and ATLASSIAN_API_TOKEN conflict; configure only one token source"
143        );
144    }
145    let instance_url = instance_override
146        .map(str::to_owned)
147        .or(scope.instance_url)
148        .filter(|v| !v.trim().is_empty())
149        .ok_or(AtlassianError::CredentialsNotFound)?;
150    let auth = if scope.has_pat {
151        AtlassianAuth::Bearer {
152            token: secret_var(env, ATLASSIAN_PAT)?.ok_or(AtlassianError::CredentialsNotFound)?,
153        }
154    } else {
155        AtlassianAuth::Basic {
156            email: env
157                .var(ATLASSIAN_EMAIL)
158                .filter(|v| !v.trim().is_empty())
159                .ok_or(AtlassianError::CredentialsNotFound)?,
160            api_token: secret_var(env, ATLASSIAN_API_TOKEN)?
161                .ok_or(AtlassianError::CredentialsNotFound)?,
162        }
163    };
164    auth.validate()?;
165    Ok(AtlassianCredentials {
166        instance_url: instance_url.trim_end_matches('/').to_string(),
167        auth,
168    })
169}
170
171/// Configuration presence, without resolving or authenticating secrets.
172#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
173#[serde(rename_all = "snake_case")]
174pub enum ConfigurationState {
175    /// No authentication keys are present.
176    Missing,
177    /// Required configuration is absent.
178    Incomplete,
179    /// Required configuration is present; not remotely validated.
180    Configured,
181    /// Both authentication token sources are configured.
182    Conflict,
183}
184
185/// Summary of a single Atlassian credential scope.
186///
187/// Reports which credential keys are present without exposing their values.
188/// Safe to serialize and return over the MCP surface.
189#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
190pub struct AtlassianScopeStatus {
191    /// Scope name (currently always `"default"`; forward-compatible for
192    /// per-instance scopes).
193    pub name: String,
194    /// Whether [`ATLASSIAN_EMAIL`] is present.
195    pub has_email: bool,
196    /// Whether [`ATLASSIAN_API_TOKEN`] is present. Token value is never exposed.
197    pub has_token: bool,
198    /// Whether a PAT source is present, without resolving it.
199    pub has_pat: bool,
200    /// Unambiguous selected authentication mode, if any.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub auth_mode: Option<AuthMode>,
203    /// Presence classification; never a claim of authentication success.
204    pub configuration_state: ConfigurationState,
205    /// Value of [`ATLASSIAN_INSTANCE_URL`] when set. The URL is considered
206    /// non-secret; returning it helps the assistant surface which instance
207    /// a scope targets without exposing credentials.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub instance_url: Option<String>,
210}
211
212/// Aggregate credential status across every known Atlassian scope.
213#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
214pub struct AuthStatus {
215    /// One entry per scope. Currently a single default scope; kept as a list
216    /// so future multi-instance support does not require a schema change.
217    pub scopes: Vec<AtlassianScopeStatus>,
218}
219
220/// Builds an [`AuthStatus`] from the current settings / environment.
221///
222/// Reports credential presence without leaking any secret values.
223/// [`AtlassianScopeStatus::instance_url`] is returned verbatim when set —
224/// URLs are explicitly non-secret; tokens and emails are flagged as booleans
225/// only. Safe to call with no credentials configured (returns a scope with
226/// every flag `false`).
227pub fn status() -> AuthStatus {
228    let settings = Settings::load_or_warn_default();
229
230    status_from(&settings.env_source())
231}
232
233/// Reports presence through an injected source, without resolving secrets.
234pub(crate) fn status_from(env: &impl EnvSource) -> AuthStatus {
235    AuthStatus {
236        scopes: vec![scope_status_from(env)],
237    }
238}
239
240/// Classifies credential presence without reading files or executing helpers.
241fn scope_status_from(env: &impl EnvSource) -> AtlassianScopeStatus {
242    let instance_url = env
243        .var(ATLASSIAN_INSTANCE_URL)
244        .filter(|v| !v.trim().is_empty())
245        .map(|v| v.trim_end_matches('/').to_string());
246    let has_email = env
247        .var(ATLASSIAN_EMAIL)
248        .is_some_and(|v| !v.trim().is_empty());
249    let has_token = secret_var_is_set(env, ATLASSIAN_API_TOKEN);
250    let has_pat = secret_var_is_set(env, ATLASSIAN_PAT);
251    let auth_mode = match (has_token, has_pat) {
252        (true, false) => Some(AuthMode::Basic),
253        (false, true) => Some(AuthMode::Bearer),
254        _ => None,
255    };
256    let configuration_state = if has_token && has_pat {
257        ConfigurationState::Conflict
258    } else if (has_pat || (has_token && has_email)) && instance_url.is_some() {
259        ConfigurationState::Configured
260    } else if !has_email && !has_token && !has_pat && instance_url.is_none() {
261        ConfigurationState::Missing
262    } else {
263        ConfigurationState::Incomplete
264    };
265    AtlassianScopeStatus {
266        name: "default".to_string(),
267        has_email,
268        has_token,
269        has_pat,
270        auth_mode,
271        configuration_state,
272        instance_url,
273    }
274}
275
276/// Saves Atlassian credentials to `~/.omni-dev/settings.json`.
277///
278/// Reads the existing settings file, merges the new credential keys into
279/// the active profile's `env` map (the base `env` when no profile is active
280/// — issue #1116), and writes back. Preserves all other settings.
281pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
282    save_credentials_to(
283        &Settings::get_settings_path()?,
284        active_profile_from(&SystemEnv).as_deref(),
285        credentials,
286    )
287}
288
289/// [`save_credentials`], writing to an explicit settings-file path and env
290/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
291///
292/// Tests inject a tempdir path and an explicit profile instead of mutating
293/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
294pub(crate) fn save_credentials_to(
295    settings_path: &std::path::Path,
296    profile: Option<&str>,
297    credentials: &AtlassianCredentials,
298) -> Result<()> {
299    credentials.auth.validate()?;
300    if credentials.instance_url.trim().is_empty() {
301        anyhow::bail!("Instance URL is required");
302    }
303    let mut vars = vec![(ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str())];
304    let remove = match &credentials.auth {
305        AtlassianAuth::Basic { email, api_token } => {
306            vars.extend([
307                (ATLASSIAN_EMAIL, email.as_str()),
308                (ATLASSIAN_API_TOKEN, api_token.expose_secret()),
309            ]);
310            vec![ATLASSIAN_PAT]
311        }
312        AtlassianAuth::Bearer { token } => {
313            vars.push((ATLASSIAN_PAT, token.expose_secret()));
314            vec![ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN]
315        }
316    };
317    Settings::replace_env_vars_in(settings_path, profile, &vars, &remove)
318}
319
320/// Removes Atlassian credential keys from `~/.omni-dev/settings.json` — from
321/// the active profile's `env` map when a profile is active, the base `env`
322/// otherwise (issue #1116).
323///
324/// Leaves all other settings intact. Returns `true` if any Atlassian key was
325/// present and removed, `false` when the targeted map was already free of
326/// them (or the file did not exist).
327pub fn remove_credentials() -> Result<bool> {
328    remove_credentials_at(
329        &Settings::get_settings_path()?,
330        active_profile_from(&SystemEnv).as_deref(),
331    )
332}
333
334/// [`remove_credentials`], operating on an explicit settings-file path and
335/// env map (`profiles.<name>.env` when `profile` is `Some`, base `env`
336/// otherwise).
337///
338/// Tests inject a tempdir path and an explicit profile instead of mutating
339/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
340pub(crate) fn remove_credentials_at(
341    settings_path: &std::path::Path,
342    profile: Option<&str>,
343) -> Result<bool> {
344    Settings::remove_env_vars_in(
345        settings_path,
346        profile,
347        &[
348            ATLASSIAN_INSTANCE_URL,
349            ATLASSIAN_EMAIL,
350            ATLASSIAN_API_TOKEN,
351            ATLASSIAN_PAT,
352        ],
353    )
354}
355
356/// Crate-internal test utilities for code that calls [`load_credentials`] /
357/// [`crate::cli::atlassian::helpers::create_client`]. Lives here because it
358/// needs the credential constants and shares process-wide env state with
359/// auth.rs's own tests — every consumer must serialise on
360/// [`AUTH_ENV_MUTEX`] to avoid racing.
361#[cfg(test)]
362#[allow(clippy::unwrap_used, clippy::expect_used)]
363pub(crate) mod test_util {
364    use super::{
365        ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_OVERRIDE_ENV,
366        ATLASSIAN_INSTANCE_URL, ATLASSIAN_PAT,
367    };
368    use crate::utils::settings::PROFILE_ENV_VAR;
369
370    /// Mutex shared by every test that mutates `HOME`, `OMNI_DEV_PROFILE`, or
371    /// the Atlassian credential env vars. Serialises those tests against each
372    /// other so parallel execution doesn't race on process-wide env state.
373    ///
374    /// Aliases the crate-wide [`crate::test_support::HOME_ENV_MUTEX`] rather
375    /// than declaring its own `Mutex<()>`, so Atlassian's HOME mutation also
376    /// serialises against every other domain's (Datadog, Gmail, …) — see
377    /// that static's doc comment for why an independent mutex here provides
378    /// no real exclusion.
379    pub(crate) static AUTH_ENV_MUTEX: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
380
381    /// RAII guard: snapshots `HOME`, `OMNI_DEV_PROFILE`, and every Atlassian
382    /// credential env var on construction and restores them on drop.
383    /// Concentrating the save/restore branches into one place (here) instead
384    /// of inlining them in each test keeps coverage high — every test
385    /// exercises the same guard drop path.
386    pub(crate) struct EnvGuard {
387        _lock: std::sync::MutexGuard<'static, ()>,
388        snapshot: Vec<(&'static str, Option<String>)>,
389    }
390
391    impl EnvGuard {
392        pub(crate) fn take() -> Self {
393            let lock = AUTH_ENV_MUTEX
394                .lock()
395                .unwrap_or_else(std::sync::PoisonError::into_inner);
396            let keys = [
397                "HOME",
398                PROFILE_ENV_VAR,
399                ATLASSIAN_INSTANCE_URL,
400                ATLASSIAN_INSTANCE_OVERRIDE_ENV,
401                ATLASSIAN_EMAIL,
402                ATLASSIAN_API_TOKEN,
403                ATLASSIAN_PAT,
404            ];
405            let snapshot = keys
406                .into_iter()
407                .map(|k| (k, std::env::var(k).ok()))
408                .collect();
409            Self {
410                _lock: lock,
411                snapshot,
412            }
413        }
414
415        /// Clears the three Atlassian credential env vars plus
416        /// `OMNI_DEV_PROFILE` and points `HOME` at a fresh empty tempdir so
417        /// `load_credentials()` returns `CredentialsNotFound` and settings
418        /// writes target the base `env` map. Useful for testing the Err
419        /// propagation path of code that calls `create_client()` and the
420        /// `HOME`-resolving credential-write wrappers.
421        pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
422            let dir = {
423                std::fs::create_dir_all("tmp").ok();
424                tempfile::TempDir::new_in("tmp").unwrap()
425            };
426            std::env::set_var("HOME", dir.path());
427            std::env::remove_var(PROFILE_ENV_VAR);
428            std::env::remove_var(ATLASSIAN_INSTANCE_URL);
429            std::env::remove_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV);
430            std::env::remove_var(ATLASSIAN_EMAIL);
431            std::env::remove_var(ATLASSIAN_API_TOKEN);
432            std::env::remove_var(ATLASSIAN_PAT);
433            dir
434        }
435
436        /// Sets the three Atlassian credential env vars to point at a wiremock
437        /// (or any HTTP) endpoint. The matching `HOME` is replaced with a
438        /// fresh tempdir so any `~/.omni-dev/settings.json` on the developer's
439        /// machine does not bleed in.
440        pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
441            let dir = {
442                std::fs::create_dir_all("tmp").ok();
443                tempfile::TempDir::new_in("tmp").unwrap()
444            };
445            std::env::set_var("HOME", dir.path());
446            std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
447            std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
448            std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
449            std::env::remove_var(ATLASSIAN_PAT);
450            dir
451        }
452    }
453
454    impl Drop for EnvGuard {
455        fn drop(&mut self) {
456            for (k, v) in &self.snapshot {
457                match v {
458                    Some(val) => std::env::set_var(k, val),
459                    None => std::env::remove_var(k),
460                }
461            }
462        }
463    }
464}
465
466#[cfg(test)]
467#[allow(clippy::unwrap_used, clippy::expect_used)]
468mod tests {
469    use std::fs;
470
471    use super::*;
472
473    #[test]
474    fn save_and_read_credentials() {
475        let temp_dir = {
476            std::fs::create_dir_all("tmp").ok();
477            tempfile::TempDir::new_in("tmp").unwrap()
478        };
479        let settings_path = temp_dir.path().join("settings.json");
480
481        // Start with existing settings
482        let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
483        fs::write(&settings_path, existing).unwrap();
484
485        // Read it back as a value, add credentials, write
486        let content = fs::read_to_string(&settings_path).unwrap();
487        let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
488        val["env"]["ATLASSIAN_INSTANCE_URL"] =
489            serde_json::Value::String("https://test.atlassian.net".to_string());
490        val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
491        val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
492        let formatted = serde_json::to_string_pretty(&val).unwrap();
493        fs::write(&settings_path, formatted).unwrap();
494
495        // Verify existing keys are preserved
496        let content = fs::read_to_string(&settings_path).unwrap();
497        let val: serde_json::Value = serde_json::from_str(&content).unwrap();
498        assert_eq!(val["env"]["SOME_KEY"], "value");
499        assert_eq!(
500            val["env"]["ATLASSIAN_INSTANCE_URL"],
501            "https://test.atlassian.net"
502        );
503        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
504        assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
505    }
506
507    #[test]
508    fn load_credentials_normalizes_trailing_slash() {
509        // Test the trailing-slash normalization logic directly
510        let url = "https://env.atlassian.net/";
511        let normalized = url.trim_end_matches('/').to_string();
512        assert_eq!(normalized, "https://env.atlassian.net");
513    }
514
515    #[test]
516    fn constant_key_names() {
517        assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
518        assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
519        assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
520    }
521
522    #[test]
523    fn credentials_struct_clone_and_debug() {
524        let creds = AtlassianCredentials {
525            instance_url: "https://org.atlassian.net".to_string(),
526            auth: crate::atlassian::auth::AtlassianAuth::Basic {
527                email: "user@test.com".to_string(),
528                api_token: "super-sekret-api-token-value".into(),
529            },
530        };
531        let cloned = creds.clone();
532        assert_eq!(cloned.instance_url, creds.instance_url);
533        assert_eq!(cloned.auth, creds.auth);
534        // Debug must never print the token value (#1131).
535        let debug = format!("{creds:?}");
536        assert!(debug.contains("AtlassianCredentials"));
537        assert!(
538            !debug.contains("super-sekret-api-token-value"),
539            "leaked token: {debug}"
540        );
541        assert!(debug.contains("api_token: <redacted>"));
542    }
543
544    use super::test_util::EnvGuard;
545
546    fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
547        let dir = {
548            std::fs::create_dir_all("tmp").ok();
549            tempfile::TempDir::new_in("tmp").unwrap()
550        };
551        std::env::set_var("HOME", dir.path());
552        std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
553        std::env::remove_var(ATLASSIAN_INSTANCE_URL);
554        std::env::remove_var(ATLASSIAN_EMAIL);
555        std::env::remove_var(ATLASSIAN_API_TOKEN);
556        std::env::remove_var(ATLASSIAN_PAT);
557        dir
558    }
559
560    #[test]
561    fn status_reports_all_false_when_nothing_configured() {
562        let guard = EnvGuard::take();
563        let _dir = with_empty_home(&guard);
564
565        let status = status();
566        assert_eq!(status.scopes.len(), 1);
567        let scope = &status.scopes[0];
568        assert_eq!(scope.name, "default");
569        assert!(!scope.has_email);
570        assert!(!scope.has_token);
571        assert_eq!(scope.instance_url, None);
572    }
573
574    #[test]
575    fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
576        let guard = EnvGuard::take();
577        let dir = with_empty_home(&guard);
578        let omni_dir = dir.path().join(".omni-dev");
579        fs::create_dir_all(&omni_dir).unwrap();
580        fs::write(
581            omni_dir.join("settings.json"),
582            r#"{"env":{
583                "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
584                "ATLASSIAN_EMAIL":"person@example.com",
585                "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
586            }}"#,
587        )
588        .unwrap();
589
590        let status = status();
591        assert_eq!(status.scopes.len(), 1);
592        let scope = &status.scopes[0];
593        assert!(scope.has_email);
594        assert!(scope.has_token);
595        assert_eq!(
596            scope.instance_url.as_deref(),
597            Some("https://status.atlassian.net")
598        );
599
600        let yaml = serde_yaml::to_string(&status).unwrap();
601        assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
602        assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
603    }
604
605    #[test]
606    fn status_returns_instance_url_from_env_without_trailing_slash() {
607        let guard = EnvGuard::take();
608        let _dir = with_empty_home(&guard);
609        std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
610
611        let status = status();
612        let scope = &status.scopes[0];
613        assert_eq!(
614            scope.instance_url.as_deref(),
615            Some("https://env.atlassian.net")
616        );
617        assert!(!scope.has_email);
618        assert!(!scope.has_token);
619    }
620
621    /// The production wrapper resolves `~/.omni-dev/settings.json` from
622    /// `HOME`, which `dirs::home_dir()` reads internally — so this one test
623    /// must redirect `HOME` (under the shared [`EnvGuard`]). Every other save
624    /// test injects a path into `save_credentials_to` instead (issue #1030).
625    #[test]
626    fn save_credentials_resolves_default_settings_path() {
627        let guard = EnvGuard::take();
628        let dir = with_empty_home(&guard);
629
630        let creds = AtlassianCredentials {
631            instance_url: "https://wrapper.atlassian.net".to_string(),
632            auth: crate::atlassian::auth::AtlassianAuth::Basic {
633                email: "wrapper@example.com".to_string(),
634                api_token: "wrapper-token".into(),
635            },
636        };
637        save_credentials(&creds).unwrap();
638
639        let settings_path = dir.path().join(".omni-dev").join("settings.json");
640        let val: serde_json::Value =
641            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
642        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
643    }
644
645    /// The `remove_credentials()` wrapper resolves the settings path from
646    /// `HOME` and the profile from the environment; every other removal test
647    /// injects both into `remove_credentials_at` (issue #1030).
648    #[test]
649    fn remove_credentials_resolves_default_settings_path() {
650        let guard = EnvGuard::take();
651        let dir = with_empty_home(&guard);
652
653        let creds = AtlassianCredentials {
654            instance_url: "https://wrapper.atlassian.net".to_string(),
655            auth: crate::atlassian::auth::AtlassianAuth::Basic {
656                email: "wrapper@example.com".to_string(),
657                api_token: "wrapper-token".into(),
658            },
659        };
660        save_credentials(&creds).unwrap();
661
662        // Present → removed.
663        assert!(remove_credentials().unwrap());
664
665        let settings_path = dir.path().join(".omni-dev").join("settings.json");
666        let val: serde_json::Value =
667            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
668        assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
669        assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
670
671        // Idempotent: nothing left to remove.
672        assert!(!remove_credentials().unwrap());
673    }
674
675    /// Save against injected settings-file paths — no `HOME` mutation, so the
676    /// test needs no lock (issue #1030). Covers fresh-file creation and
677    /// merge-with-existing.
678    #[test]
679    fn save_credentials_creates_and_preserves() {
680        // ── Part 1: creates file from scratch ──────────────────────
681        {
682            let temp_dir = {
683                std::fs::create_dir_all("tmp").ok();
684                tempfile::TempDir::new_in("tmp").unwrap()
685            };
686            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
687
688            let creds = AtlassianCredentials {
689                instance_url: "https://save.atlassian.net".to_string(),
690                auth: crate::atlassian::auth::AtlassianAuth::Basic {
691                    email: "save@example.com".to_string(),
692                    api_token: "save-token".into(),
693                },
694            };
695            save_credentials_to(&settings_path, None, &creds).unwrap();
696
697            assert!(settings_path.exists());
698            let content = fs::read_to_string(&settings_path).unwrap();
699            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
700            assert_eq!(
701                val["env"]["ATLASSIAN_INSTANCE_URL"],
702                "https://save.atlassian.net"
703            );
704            assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
705            assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
706
707            // The credential store is created owner-only (issue #1128).
708            #[cfg(unix)]
709            {
710                use std::os::unix::fs::PermissionsExt;
711                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
712                assert_eq!(mode & 0o777, 0o600);
713            }
714        }
715
716        // ── Part 2: preserves existing keys ────────────────────────
717        {
718            let temp_dir = {
719                std::fs::create_dir_all("tmp").ok();
720                tempfile::TempDir::new_in("tmp").unwrap()
721            };
722            let omni_dir = temp_dir.path().join(".omni-dev");
723            fs::create_dir_all(&omni_dir).unwrap();
724            let settings_path = omni_dir.join("settings.json");
725            fs::write(
726                &settings_path,
727                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
728            )
729            .unwrap();
730
731            let creds = AtlassianCredentials {
732                instance_url: "https://org.atlassian.net".to_string(),
733                auth: crate::atlassian::auth::AtlassianAuth::Basic {
734                    email: "user@test.com".to_string(),
735                    api_token: "token".into(),
736                },
737            };
738            save_credentials_to(&settings_path, None, &creds).unwrap();
739
740            let content = fs::read_to_string(&settings_path).unwrap();
741            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
742            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
743            assert_eq!(val["extra"], true);
744            assert_eq!(
745                val["env"]["ATLASSIAN_INSTANCE_URL"],
746                "https://org.atlassian.net"
747            );
748        }
749    }
750
751    /// A profile-targeted save lands under `profiles.<name>.env` — where the
752    /// profile-aware read side will find it — and leaves the base `env`
753    /// untouched (issue #1116).
754    #[test]
755    fn save_credentials_to_profile_writes_into_profile_env() {
756        let temp_dir = {
757            std::fs::create_dir_all("tmp").ok();
758            tempfile::TempDir::new_in("tmp").unwrap()
759        };
760        let omni_dir = temp_dir.path().join(".omni-dev");
761        fs::create_dir_all(&omni_dir).unwrap();
762        let settings_path = omni_dir.join("settings.json");
763        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
764
765        let creds = AtlassianCredentials {
766            instance_url: "https://work.atlassian.net".to_string(),
767            auth: crate::atlassian::auth::AtlassianAuth::Basic {
768                email: "work@example.com".to_string(),
769                api_token: "work-token".into(),
770            },
771        };
772        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
773
774        let val: serde_json::Value =
775            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
776        assert_eq!(
777            val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
778            "work@example.com"
779        );
780        assert_eq!(
781            val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
782            "https://work.atlassian.net"
783        );
784        assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
785        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
786    }
787
788    #[test]
789    fn load_credentials_with_instance_override_supplies_instance_url() {
790        // The override lets a caller (e.g. `jira create --instance`) target an
791        // instance even when ATLASSIAN_INSTANCE_URL is unset, as long as email
792        // and token are present. The trailing slash is normalized.
793        let guard = EnvGuard::take();
794        let _dir = with_empty_home(&guard);
795        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
796        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
797
798        let creds =
799            load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
800        assert_eq!(creds.instance_url, "https://override.atlassian.net");
801        assert!(
802            matches!(&creds.auth, AtlassianAuth::Basic { email, .. } if email == "person@example.com")
803        );
804        assert!(
805            matches!(&creds.auth, AtlassianAuth::Basic { api_token, .. } if api_token.expose_secret() == "token")
806        );
807    }
808
809    #[test]
810    fn load_credentials_with_instance_none_requires_env_instance() {
811        // Without an override and without ATLASSIAN_INSTANCE_URL configured,
812        // resolution fails just like load_credentials() does today.
813        let guard = EnvGuard::take();
814        let _dir = with_empty_home(&guard);
815        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
816        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
817
818        assert!(load_credentials_with_instance(None).is_err());
819    }
820
821    #[test]
822    fn load_credentials_honours_instance_override_env() {
823        // The global `--instance` flag (exported as OMNI_DEV_ATLASSIAN_INSTANCE)
824        // overrides the instance for every command, even with no configured
825        // ATLASSIAN_INSTANCE_URL (#1117). A blank value is ignored.
826        let guard = EnvGuard::take();
827        let _dir = with_empty_home(&guard);
828        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
829        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
830
831        // Blank → ignored → falls back to the (absent) configured instance.
832        std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, "  ");
833        assert!(load_credentials().is_err());
834
835        // Set → used verbatim (trailing slash normalized).
836        std::env::set_var(
837            ATLASSIAN_INSTANCE_OVERRIDE_ENV,
838            "https://flag.atlassian.net/",
839        );
840        let creds = load_credentials().unwrap();
841        assert_eq!(creds.instance_url, "https://flag.atlassian.net");
842        assert!(
843            matches!(&creds.auth, AtlassianAuth::Basic { email, .. } if email == "person@example.com")
844        );
845    }
846    use crate::test_support::env::{secret_file, MapEnv};
847
848    fn pat_env() -> MapEnv {
849        MapEnv::new()
850            .with(ATLASSIAN_INSTANCE_URL, "https://self.example/jira/")
851            .with(ATLASSIAN_PAT, "private-pat")
852    }
853
854    #[test]
855    fn pat_loads_without_email_and_is_redacted() {
856        let creds = load_credentials_from(&pat_env(), None).unwrap();
857        assert_eq!(creds.instance_url, "https://self.example/jira");
858        assert_eq!(creds.auth.mode(), AuthMode::Bearer);
859        assert!(!format!("{creds:?}").contains("private-pat"));
860        let scope = scope_status_from(&pat_env().with(ATLASSIAN_EMAIL, "leftover@example.com"));
861        assert_eq!(scope.configuration_state, ConfigurationState::Configured);
862        assert_eq!(scope.auth_mode, Some(AuthMode::Bearer));
863        let yaml = serde_yaml::to_string(&scope).unwrap();
864        assert!(yaml.contains("has_pat: true"));
865        assert!(!yaml.contains("private-pat"));
866        assert!(!yaml.contains("leftover@example.com"));
867    }
868
869    #[test]
870    fn token_conflict_precedes_secret_resolution() {
871        let env = MapEnv::new()
872            .with(ATLASSIAN_INSTANCE_URL, "https://self.example")
873            .with("ATLASSIAN_PAT_COMMAND", "/does/not/exist")
874            .with("ATLASSIAN_API_TOKEN_FILE", "/does/not/exist");
875        let scope = scope_status_from(&env);
876        assert_eq!(scope.configuration_state, ConfigurationState::Conflict);
877        assert_eq!(scope.auth_mode, None);
878        let err = load_credentials_from(&env, None).unwrap_err().to_string();
879        assert!(err.contains("conflict"));
880        assert!(!err.contains("/does/not/exist"));
881    }
882
883    #[test]
884    fn presence_status_does_not_resolve_sources() {
885        for key in ["ATLASSIAN_PAT_FILE", "ATLASSIAN_PAT_COMMAND"] {
886            let env = MapEnv::new()
887                .with(ATLASSIAN_INSTANCE_URL, "https://self.example")
888                .with(key, "/does/not/exist");
889            assert_eq!(
890                scope_status_from(&env).configuration_state,
891                ConfigurationState::Configured
892            );
893        }
894        assert_eq!(
895            scope_status_from(&MapEnv::new()).configuration_state,
896            ConfigurationState::Missing
897        );
898        assert_eq!(
899            scope_status_from(&MapEnv::new().with(ATLASSIAN_PAT, "pat")).configuration_state,
900            ConfigurationState::Incomplete
901        );
902    }
903
904    #[test]
905    fn pat_file_and_helper_use_secret_resolver() {
906        let (_dir, path) = secret_file("file-pat\n");
907        let env = MapEnv::new().with("ATLASSIAN_PAT_FILE", &path);
908        let creds = load_credentials_from(&env, Some("https://self.example/confluence/")).unwrap();
909        assert!(
910            matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "file-pat")
911        );
912        // The printf helper is Unix-specific; the file-source case above is portable.
913        #[cfg(unix)]
914        {
915            let env = MapEnv::new().with("ATLASSIAN_PAT_COMMAND", "/usr/bin/printf helper-pat");
916            let creds = load_credentials_from(&env, Some("https://self.example")).unwrap();
917            assert!(
918                matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "helper-pat")
919            );
920        }
921    }
922
923    #[test]
924    fn invalid_and_missing_credentials_do_not_fall_back() {
925        for token in [" ", "pat\r\nInjected: yes"] {
926            let env = MapEnv::new().with(ATLASSIAN_PAT, token);
927            let err = load_credentials_from(&env, Some("https://self.example")).unwrap_err();
928            if !token.trim().is_empty() {
929                assert!(!err.to_string().contains(token));
930            }
931        }
932        let env = MapEnv::new().with(ATLASSIAN_API_TOKEN, "cloud-token");
933        assert!(load_credentials_from(&env, Some("https://self.example")).is_err());
934        assert!(load_credentials_from(&MapEnv::new(), Some("https://self.example")).is_err());
935        assert!(load_credentials_from(&pat_env(), Some(" ")).is_err());
936        let env = MapEnv::new()
937            .with(ATLASSIAN_PAT, "pat")
938            .with("ATLASSIAN_PAT_FILE", "/missing");
939        assert!(load_credentials_from(&env, Some("https://self.example")).is_err());
940    }
941
942    struct Layered<'a> {
943        settings: &'a Settings,
944        raw: MapEnv,
945        profile: Option<&'a str>,
946    }
947    impl EnvSource for Layered<'_> {
948        fn var(&self, key: &str) -> Option<String> {
949            self.settings.resolve_with(&self.raw, self.profile, key)
950        }
951        fn var_triple(
952            &self,
953            key: &str,
954            file: &str,
955            command: &str,
956        ) -> crate::utils::env::SecretTriple {
957            self.settings
958                .resolve_triple_with(&self.raw, self.profile, key, file, command)
959        }
960    }
961
962    #[test]
963    fn pat_profiles_are_isolated_and_process_source_wins() {
964        let settings: Settings = serde_json::from_value(serde_json::json!({
965            "env": {"ATLASSIAN_EMAIL": "base@example.com", "ATLASSIAN_API_TOKEN": "base-token"},
966            "profiles": {"work": {"env": {"ATLASSIAN_PAT": "profile-pat"}}}
967        }))
968        .unwrap();
969        let source = Layered {
970            settings: &settings,
971            raw: MapEnv::new(),
972            profile: Some("work"),
973        };
974        let creds = load_credentials_from(&source, Some("https://self.example")).unwrap();
975        assert!(
976            matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "profile-pat")
977        );
978        let source = Layered {
979            raw: MapEnv::new().with(ATLASSIAN_PAT, "process-pat"),
980            ..source
981        };
982        let creds = load_credentials_from(&source, Some("https://self.example")).unwrap();
983        assert!(
984            matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "process-pat")
985        );
986    }
987
988    #[test]
989    fn mode_switch_and_logout_remove_obsolete_sources_atomically() {
990        let dir = tempfile::tempdir().unwrap();
991        let path = dir.path().join("settings.json");
992        let initial = serde_json::json!({"unknown": true, "env": {"KEEP": "base"}, "profiles": {
993            "work": {"env": {"KEEP": "work", "ATLASSIAN_EMAIL": "old@example.com",
994                "ATLASSIAN_API_TOKEN_FILE": "/old-file", "ATLASSIAN_API_TOKEN_COMMAND": "old-helper"}},
995            "other": {"env": {"ATLASSIAN_PAT": "other-pat"}}
996        }});
997        fs::write(&path, initial.to_string()).unwrap();
998        let creds = AtlassianCredentials {
999            instance_url: "https://self.example".into(),
1000            auth: AtlassianAuth::Bearer {
1001                token: "new-pat".into(),
1002            },
1003        };
1004        save_credentials_to(&path, Some("work"), &creds).unwrap();
1005        let saved: serde_json::Value =
1006            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1007        let env = &saved["profiles"]["work"]["env"];
1008        assert_eq!(env[ATLASSIAN_PAT], "new-pat");
1009        for key in [
1010            ATLASSIAN_EMAIL,
1011            ATLASSIAN_API_TOKEN,
1012            "ATLASSIAN_API_TOKEN_FILE",
1013            "ATLASSIAN_API_TOKEN_COMMAND",
1014        ] {
1015            assert!(env.get(key).is_none());
1016        }
1017        assert_eq!(saved["env"], initial["env"]);
1018        assert_eq!(saved["profiles"]["other"], initial["profiles"]["other"]);
1019        assert_eq!(saved["unknown"], true);
1020        let creds = AtlassianCredentials {
1021            instance_url: "https://cloud.example".into(),
1022            auth: AtlassianAuth::Basic {
1023                email: "me@example.com".into(),
1024                api_token: "cloud-token".into(),
1025            },
1026        };
1027        save_credentials_to(&path, Some("work"), &creds).unwrap();
1028        let saved: serde_json::Value =
1029            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1030        assert!(saved["profiles"]["work"]["env"]
1031            .get(ATLASSIAN_PAT)
1032            .is_none());
1033        assert!(remove_credentials_at(&path, Some("work")).unwrap());
1034        assert!(!remove_credentials_at(&path, Some("work")).unwrap());
1035    }
1036
1037    #[test]
1038    fn selected_helper_refusal_leaves_file_unchanged() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let path = dir.path().join("settings.json");
1041        let initial = r#"{"env":{"ATLASSIAN_PAT_COMMAND":"helper","ATLASSIAN_API_TOKEN":"old"}}"#;
1042        fs::write(&path, initial).unwrap();
1043        let creds = AtlassianCredentials {
1044            instance_url: "https://self.example".into(),
1045            auth: AtlassianAuth::Bearer {
1046                token: "new-pat".into(),
1047            },
1048        };
1049        assert!(save_credentials_to(&path, None, &creds).is_err());
1050        assert_eq!(fs::read_to_string(&path).unwrap(), initial);
1051    }
1052}