Skip to main content

omni_dev/atlassian/
auth.rs

1//! Atlassian credential management.
2//!
3//! Loads and saves Atlassian Cloud API 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::SystemEnv;
12use crate::utils::secret::Secret;
13use crate::utils::settings::{active_profile_from, Settings};
14
15/// Environment variable / settings key for the Atlassian instance URL.
16pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
17
18/// Environment variable / settings key for the Atlassian user email.
19pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
20
21/// Environment variable / settings key for the Atlassian API token.
22pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
23
24/// Environment variable that overrides the Atlassian instance URL.
25///
26/// Applies to **every** JIRA/Confluence command. Set by the global `--instance`
27/// flag via [`crate::cli::Cli`]'s `propagate_global_flags`; takes precedence
28/// over `ATLASSIAN_INSTANCE_URL` / settings.json. A caller-supplied override
29/// (e.g. via [`load_credentials_with_instance`]) still wins over it.
30pub const ATLASSIAN_INSTANCE_OVERRIDE_ENV: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
31
32/// Atlassian Cloud credentials.
33#[derive(Debug, Clone)]
34pub struct AtlassianCredentials {
35    /// Instance base URL (e.g., `"https://myorg.atlassian.net"`).
36    pub instance_url: String,
37
38    /// User email address.
39    pub email: String,
40
41    /// API token (secret; redacted in `Debug` output).
42    pub api_token: Secret,
43}
44
45/// Loads Atlassian credentials from environment variables or settings.json.
46///
47/// Checks environment variables first, then falls back to the settings file.
48/// The global `--instance` flag (propagated to [`ATLASSIAN_INSTANCE_OVERRIDE_ENV`])
49/// overrides the configured instance URL for every command; a blank value is
50/// ignored. Callers that carry their own explicit override should call
51/// [`load_credentials_with_instance`] directly.
52pub fn load_credentials() -> Result<AtlassianCredentials> {
53    let env_override = std::env::var(ATLASSIAN_INSTANCE_OVERRIDE_ENV)
54        .ok()
55        .filter(|s| !s.trim().is_empty());
56    load_credentials_with_instance(env_override.as_deref())
57}
58
59/// Loads Atlassian credentials, optionally overriding the instance URL.
60///
61/// When `instance_override` is `Some`, that URL is used verbatim (after
62/// trailing-slash normalization) and the `ATLASSIAN_INSTANCE_URL` env /
63/// settings lookup is skipped — so a caller-supplied instance (e.g.
64/// `jira create --instance`) works even when no instance is configured in the
65/// environment. `ATLASSIAN_EMAIL` and `ATLASSIAN_API_TOKEN` are still required.
66/// When `None`, behaves exactly like [`load_credentials`].
67pub fn load_credentials_with_instance(
68    instance_override: Option<&str>,
69) -> Result<AtlassianCredentials> {
70    let settings = Settings::load().unwrap_or_default();
71
72    let instance_url = match instance_override {
73        Some(url) => url.to_string(),
74        None => settings
75            .get_env_var(ATLASSIAN_INSTANCE_URL)
76            .ok_or(AtlassianError::CredentialsNotFound)?,
77    };
78    let email = settings
79        .get_env_var(ATLASSIAN_EMAIL)
80        .ok_or(AtlassianError::CredentialsNotFound)?;
81    let api_token = settings
82        .get_env_var(ATLASSIAN_API_TOKEN)
83        .ok_or(AtlassianError::CredentialsNotFound)?;
84
85    // Normalize: strip trailing slash from instance URL
86    let instance_url = instance_url.trim_end_matches('/').to_string();
87
88    Ok(AtlassianCredentials {
89        instance_url,
90        email,
91        api_token: api_token.into(),
92    })
93}
94
95/// Summary of a single Atlassian credential scope.
96///
97/// Reports which credential keys are present without exposing their values.
98/// Safe to serialize and return over the MCP surface.
99#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
100pub struct AtlassianScopeStatus {
101    /// Scope name (currently always `"default"`; forward-compatible for
102    /// per-instance scopes).
103    pub name: String,
104    /// Whether [`ATLASSIAN_EMAIL`] is present.
105    pub has_email: bool,
106    /// Whether [`ATLASSIAN_API_TOKEN`] is present. Token value is never exposed.
107    pub has_token: bool,
108    /// Value of [`ATLASSIAN_INSTANCE_URL`] when set. The URL is considered
109    /// non-secret; returning it helps the assistant surface which instance
110    /// a scope targets without exposing credentials.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub instance_url: Option<String>,
113}
114
115/// Aggregate credential status across every known Atlassian scope.
116#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
117pub struct AuthStatus {
118    /// One entry per scope. Currently a single default scope; kept as a list
119    /// so future multi-instance support does not require a schema change.
120    pub scopes: Vec<AtlassianScopeStatus>,
121}
122
123/// Builds an [`AuthStatus`] from the current settings / environment.
124///
125/// Reports credential presence without leaking any secret values.
126/// [`AtlassianScopeStatus::instance_url`] is returned verbatim when set —
127/// URLs are explicitly non-secret; tokens and emails are flagged as booleans
128/// only. Safe to call with no credentials configured (returns a scope with
129/// every flag `false`).
130pub fn status() -> AuthStatus {
131    let settings = Settings::load().unwrap_or_default();
132
133    let instance_url = settings
134        .get_env_var(ATLASSIAN_INSTANCE_URL)
135        .map(|v| v.trim_end_matches('/').to_string());
136    let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
137    let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
138
139    AuthStatus {
140        scopes: vec![AtlassianScopeStatus {
141            name: "default".to_string(),
142            has_email,
143            has_token,
144            instance_url,
145        }],
146    }
147}
148
149/// Saves Atlassian credentials to `~/.omni-dev/settings.json`.
150///
151/// Reads the existing settings file, merges the new credential keys into
152/// the active profile's `env` map (the base `env` when no profile is active
153/// — issue #1116), and writes back. Preserves all other settings.
154pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
155    save_credentials_to(
156        &Settings::get_settings_path()?,
157        active_profile_from(&SystemEnv).as_deref(),
158        credentials,
159    )
160}
161
162/// [`save_credentials`], writing to an explicit settings-file path and env
163/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
164///
165/// Tests inject a tempdir path and an explicit profile instead of mutating
166/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
167pub(crate) fn save_credentials_to(
168    settings_path: &std::path::Path,
169    profile: Option<&str>,
170    credentials: &AtlassianCredentials,
171) -> Result<()> {
172    Settings::upsert_env_vars_in(
173        settings_path,
174        profile,
175        &[
176            (ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str()),
177            (ATLASSIAN_EMAIL, credentials.email.as_str()),
178            (ATLASSIAN_API_TOKEN, credentials.api_token.expose_secret()),
179        ],
180    )
181}
182
183/// Removes Atlassian credential keys from `~/.omni-dev/settings.json` — from
184/// the active profile's `env` map when a profile is active, the base `env`
185/// otherwise (issue #1116).
186///
187/// Leaves all other settings intact. Returns `true` if any Atlassian key was
188/// present and removed, `false` when the targeted map was already free of
189/// them (or the file did not exist).
190pub fn remove_credentials() -> Result<bool> {
191    remove_credentials_at(
192        &Settings::get_settings_path()?,
193        active_profile_from(&SystemEnv).as_deref(),
194    )
195}
196
197/// [`remove_credentials`], operating on an explicit settings-file path and
198/// env map (`profiles.<name>.env` when `profile` is `Some`, base `env`
199/// otherwise).
200///
201/// Tests inject a tempdir path and an explicit profile instead of mutating
202/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
203pub(crate) fn remove_credentials_at(
204    settings_path: &std::path::Path,
205    profile: Option<&str>,
206) -> Result<bool> {
207    Settings::remove_env_vars_in(
208        settings_path,
209        profile,
210        &[ATLASSIAN_INSTANCE_URL, ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN],
211    )
212}
213
214/// Crate-internal test utilities for code that calls [`load_credentials`] /
215/// [`crate::cli::atlassian::helpers::create_client`]. Lives here because it
216/// needs the credential constants and shares process-wide env state with
217/// auth.rs's own tests — every consumer must serialise on
218/// [`AUTH_ENV_MUTEX`] to avoid racing.
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221pub(crate) mod test_util {
222    use super::{
223        ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_OVERRIDE_ENV,
224        ATLASSIAN_INSTANCE_URL,
225    };
226    use crate::utils::settings::PROFILE_ENV_VAR;
227
228    /// Mutex shared by every test that mutates `HOME`, `OMNI_DEV_PROFILE`, or
229    /// the Atlassian credential env vars. Serialises those tests against each
230    /// other so parallel execution doesn't race on process-wide env state.
231    ///
232    /// Aliases the crate-wide [`crate::test_support::HOME_ENV_MUTEX`] rather
233    /// than declaring its own `Mutex<()>`, so Atlassian's HOME mutation also
234    /// serialises against every other domain's (Datadog, Gmail, …) — see
235    /// that static's doc comment for why an independent mutex here provides
236    /// no real exclusion.
237    pub(crate) static AUTH_ENV_MUTEX: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
238
239    /// RAII guard: snapshots `HOME`, `OMNI_DEV_PROFILE`, and every Atlassian
240    /// credential env var on construction and restores them on drop.
241    /// Concentrating the save/restore branches into one place (here) instead
242    /// of inlining them in each test keeps coverage high — every test
243    /// exercises the same guard drop path.
244    pub(crate) struct EnvGuard {
245        _lock: std::sync::MutexGuard<'static, ()>,
246        snapshot: Vec<(&'static str, Option<String>)>,
247    }
248
249    impl EnvGuard {
250        pub(crate) fn take() -> Self {
251            let lock = AUTH_ENV_MUTEX
252                .lock()
253                .unwrap_or_else(std::sync::PoisonError::into_inner);
254            let keys = [
255                "HOME",
256                PROFILE_ENV_VAR,
257                ATLASSIAN_INSTANCE_URL,
258                ATLASSIAN_INSTANCE_OVERRIDE_ENV,
259                ATLASSIAN_EMAIL,
260                ATLASSIAN_API_TOKEN,
261            ];
262            let snapshot = keys
263                .into_iter()
264                .map(|k| (k, std::env::var(k).ok()))
265                .collect();
266            Self {
267                _lock: lock,
268                snapshot,
269            }
270        }
271
272        /// Clears the three Atlassian credential env vars plus
273        /// `OMNI_DEV_PROFILE` and points `HOME` at a fresh empty tempdir so
274        /// `load_credentials()` returns `CredentialsNotFound` and settings
275        /// writes target the base `env` map. Useful for testing the Err
276        /// propagation path of code that calls `create_client()` and the
277        /// `HOME`-resolving credential-write wrappers.
278        pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
279            let dir = {
280                std::fs::create_dir_all("tmp").ok();
281                tempfile::TempDir::new_in("tmp").unwrap()
282            };
283            std::env::set_var("HOME", dir.path());
284            std::env::remove_var(PROFILE_ENV_VAR);
285            std::env::remove_var(ATLASSIAN_INSTANCE_URL);
286            std::env::remove_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV);
287            std::env::remove_var(ATLASSIAN_EMAIL);
288            std::env::remove_var(ATLASSIAN_API_TOKEN);
289            dir
290        }
291
292        /// Sets the three Atlassian credential env vars to point at a wiremock
293        /// (or any HTTP) endpoint. The matching `HOME` is replaced with a
294        /// fresh tempdir so any `~/.omni-dev/settings.json` on the developer's
295        /// machine does not bleed in.
296        pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
297            let dir = {
298                std::fs::create_dir_all("tmp").ok();
299                tempfile::TempDir::new_in("tmp").unwrap()
300            };
301            std::env::set_var("HOME", dir.path());
302            std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
303            std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
304            std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
305            dir
306        }
307    }
308
309    impl Drop for EnvGuard {
310        fn drop(&mut self) {
311            for (k, v) in &self.snapshot {
312                match v {
313                    Some(val) => std::env::set_var(k, val),
314                    None => std::env::remove_var(k),
315                }
316            }
317        }
318    }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used)]
323mod tests {
324    use std::fs;
325
326    use super::*;
327
328    #[test]
329    fn save_and_read_credentials() {
330        let temp_dir = {
331            std::fs::create_dir_all("tmp").ok();
332            tempfile::TempDir::new_in("tmp").unwrap()
333        };
334        let settings_path = temp_dir.path().join("settings.json");
335
336        // Start with existing settings
337        let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
338        fs::write(&settings_path, existing).unwrap();
339
340        // Read it back as a value, add credentials, write
341        let content = fs::read_to_string(&settings_path).unwrap();
342        let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
343        val["env"]["ATLASSIAN_INSTANCE_URL"] =
344            serde_json::Value::String("https://test.atlassian.net".to_string());
345        val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
346        val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
347        let formatted = serde_json::to_string_pretty(&val).unwrap();
348        fs::write(&settings_path, formatted).unwrap();
349
350        // Verify existing keys are preserved
351        let content = fs::read_to_string(&settings_path).unwrap();
352        let val: serde_json::Value = serde_json::from_str(&content).unwrap();
353        assert_eq!(val["env"]["SOME_KEY"], "value");
354        assert_eq!(
355            val["env"]["ATLASSIAN_INSTANCE_URL"],
356            "https://test.atlassian.net"
357        );
358        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
359        assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
360    }
361
362    #[test]
363    fn load_credentials_normalizes_trailing_slash() {
364        // Test the trailing-slash normalization logic directly
365        let url = "https://env.atlassian.net/";
366        let normalized = url.trim_end_matches('/').to_string();
367        assert_eq!(normalized, "https://env.atlassian.net");
368    }
369
370    #[test]
371    fn constant_key_names() {
372        assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
373        assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
374        assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
375    }
376
377    #[test]
378    fn credentials_struct_clone_and_debug() {
379        let creds = AtlassianCredentials {
380            instance_url: "https://org.atlassian.net".to_string(),
381            email: "user@test.com".to_string(),
382            api_token: "super-sekret-api-token-value".into(),
383        };
384        let cloned = creds.clone();
385        assert_eq!(cloned.instance_url, creds.instance_url);
386        assert_eq!(cloned.email, creds.email);
387        assert_eq!(cloned.api_token, creds.api_token);
388        // Debug must never print the token value (#1131).
389        let debug = format!("{creds:?}");
390        assert!(debug.contains("AtlassianCredentials"));
391        assert!(
392            !debug.contains("super-sekret-api-token-value"),
393            "leaked token: {debug}"
394        );
395        assert!(debug.contains("api_token: <redacted>"));
396    }
397
398    use super::test_util::EnvGuard;
399
400    fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
401        let dir = {
402            std::fs::create_dir_all("tmp").ok();
403            tempfile::TempDir::new_in("tmp").unwrap()
404        };
405        std::env::set_var("HOME", dir.path());
406        std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
407        std::env::remove_var(ATLASSIAN_INSTANCE_URL);
408        std::env::remove_var(ATLASSIAN_EMAIL);
409        std::env::remove_var(ATLASSIAN_API_TOKEN);
410        dir
411    }
412
413    #[test]
414    fn status_reports_all_false_when_nothing_configured() {
415        let guard = EnvGuard::take();
416        let _dir = with_empty_home(&guard);
417
418        let status = status();
419        assert_eq!(status.scopes.len(), 1);
420        let scope = &status.scopes[0];
421        assert_eq!(scope.name, "default");
422        assert!(!scope.has_email);
423        assert!(!scope.has_token);
424        assert_eq!(scope.instance_url, None);
425    }
426
427    #[test]
428    fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
429        let guard = EnvGuard::take();
430        let dir = with_empty_home(&guard);
431        let omni_dir = dir.path().join(".omni-dev");
432        fs::create_dir_all(&omni_dir).unwrap();
433        fs::write(
434            omni_dir.join("settings.json"),
435            r#"{"env":{
436                "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
437                "ATLASSIAN_EMAIL":"person@example.com",
438                "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
439            }}"#,
440        )
441        .unwrap();
442
443        let status = status();
444        assert_eq!(status.scopes.len(), 1);
445        let scope = &status.scopes[0];
446        assert!(scope.has_email);
447        assert!(scope.has_token);
448        assert_eq!(
449            scope.instance_url.as_deref(),
450            Some("https://status.atlassian.net")
451        );
452
453        let yaml = serde_yaml::to_string(&status).unwrap();
454        assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
455        assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
456    }
457
458    #[test]
459    fn status_returns_instance_url_from_env_without_trailing_slash() {
460        let guard = EnvGuard::take();
461        let _dir = with_empty_home(&guard);
462        std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
463
464        let status = status();
465        let scope = &status.scopes[0];
466        assert_eq!(
467            scope.instance_url.as_deref(),
468            Some("https://env.atlassian.net")
469        );
470        assert!(!scope.has_email);
471        assert!(!scope.has_token);
472    }
473
474    /// The production wrapper resolves `~/.omni-dev/settings.json` from
475    /// `HOME`, which `dirs::home_dir()` reads internally — so this one test
476    /// must redirect `HOME` (under the shared [`EnvGuard`]). Every other save
477    /// test injects a path into `save_credentials_to` instead (issue #1030).
478    #[test]
479    fn save_credentials_resolves_default_settings_path() {
480        let guard = EnvGuard::take();
481        let dir = with_empty_home(&guard);
482
483        let creds = AtlassianCredentials {
484            instance_url: "https://wrapper.atlassian.net".to_string(),
485            email: "wrapper@example.com".to_string(),
486            api_token: "wrapper-token".into(),
487        };
488        save_credentials(&creds).unwrap();
489
490        let settings_path = dir.path().join(".omni-dev").join("settings.json");
491        let val: serde_json::Value =
492            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
493        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
494    }
495
496    /// The `remove_credentials()` wrapper resolves the settings path from
497    /// `HOME` and the profile from the environment; every other removal test
498    /// injects both into `remove_credentials_at` (issue #1030).
499    #[test]
500    fn remove_credentials_resolves_default_settings_path() {
501        let guard = EnvGuard::take();
502        let dir = with_empty_home(&guard);
503
504        let creds = AtlassianCredentials {
505            instance_url: "https://wrapper.atlassian.net".to_string(),
506            email: "wrapper@example.com".to_string(),
507            api_token: "wrapper-token".into(),
508        };
509        save_credentials(&creds).unwrap();
510
511        // Present → removed.
512        assert!(remove_credentials().unwrap());
513
514        let settings_path = dir.path().join(".omni-dev").join("settings.json");
515        let val: serde_json::Value =
516            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
517        assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
518        assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
519
520        // Idempotent: nothing left to remove.
521        assert!(!remove_credentials().unwrap());
522    }
523
524    /// Save against injected settings-file paths — no `HOME` mutation, so the
525    /// test needs no lock (issue #1030). Covers fresh-file creation and
526    /// merge-with-existing.
527    #[test]
528    fn save_credentials_creates_and_preserves() {
529        // ── Part 1: creates file from scratch ──────────────────────
530        {
531            let temp_dir = {
532                std::fs::create_dir_all("tmp").ok();
533                tempfile::TempDir::new_in("tmp").unwrap()
534            };
535            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
536
537            let creds = AtlassianCredentials {
538                instance_url: "https://save.atlassian.net".to_string(),
539                email: "save@example.com".to_string(),
540                api_token: "save-token".into(),
541            };
542            save_credentials_to(&settings_path, None, &creds).unwrap();
543
544            assert!(settings_path.exists());
545            let content = fs::read_to_string(&settings_path).unwrap();
546            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
547            assert_eq!(
548                val["env"]["ATLASSIAN_INSTANCE_URL"],
549                "https://save.atlassian.net"
550            );
551            assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
552            assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
553
554            // The credential store is created owner-only (issue #1128).
555            #[cfg(unix)]
556            {
557                use std::os::unix::fs::PermissionsExt;
558                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
559                assert_eq!(mode & 0o777, 0o600);
560            }
561        }
562
563        // ── Part 2: preserves existing keys ────────────────────────
564        {
565            let temp_dir = {
566                std::fs::create_dir_all("tmp").ok();
567                tempfile::TempDir::new_in("tmp").unwrap()
568            };
569            let omni_dir = temp_dir.path().join(".omni-dev");
570            fs::create_dir_all(&omni_dir).unwrap();
571            let settings_path = omni_dir.join("settings.json");
572            fs::write(
573                &settings_path,
574                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
575            )
576            .unwrap();
577
578            let creds = AtlassianCredentials {
579                instance_url: "https://org.atlassian.net".to_string(),
580                email: "user@test.com".to_string(),
581                api_token: "token".into(),
582            };
583            save_credentials_to(&settings_path, None, &creds).unwrap();
584
585            let content = fs::read_to_string(&settings_path).unwrap();
586            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
587            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
588            assert_eq!(val["extra"], true);
589            assert_eq!(
590                val["env"]["ATLASSIAN_INSTANCE_URL"],
591                "https://org.atlassian.net"
592            );
593        }
594    }
595
596    /// A profile-targeted save lands under `profiles.<name>.env` — where the
597    /// profile-aware read side will find it — and leaves the base `env`
598    /// untouched (issue #1116).
599    #[test]
600    fn save_credentials_to_profile_writes_into_profile_env() {
601        let temp_dir = {
602            std::fs::create_dir_all("tmp").ok();
603            tempfile::TempDir::new_in("tmp").unwrap()
604        };
605        let omni_dir = temp_dir.path().join(".omni-dev");
606        fs::create_dir_all(&omni_dir).unwrap();
607        let settings_path = omni_dir.join("settings.json");
608        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
609
610        let creds = AtlassianCredentials {
611            instance_url: "https://work.atlassian.net".to_string(),
612            email: "work@example.com".to_string(),
613            api_token: "work-token".into(),
614        };
615        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
616
617        let val: serde_json::Value =
618            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
619        assert_eq!(
620            val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
621            "work@example.com"
622        );
623        assert_eq!(
624            val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
625            "https://work.atlassian.net"
626        );
627        assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
628        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
629    }
630
631    #[test]
632    fn load_credentials_with_instance_override_supplies_instance_url() {
633        // The override lets a caller (e.g. `jira create --instance`) target an
634        // instance even when ATLASSIAN_INSTANCE_URL is unset, as long as email
635        // and token are present. The trailing slash is normalized.
636        let guard = EnvGuard::take();
637        let _dir = with_empty_home(&guard);
638        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
639        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
640
641        let creds =
642            load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
643        assert_eq!(creds.instance_url, "https://override.atlassian.net");
644        assert_eq!(creds.email, "person@example.com");
645        assert_eq!(creds.api_token.expose_secret(), "token");
646    }
647
648    #[test]
649    fn load_credentials_with_instance_none_requires_env_instance() {
650        // Without an override and without ATLASSIAN_INSTANCE_URL configured,
651        // resolution fails just like load_credentials() does today.
652        let guard = EnvGuard::take();
653        let _dir = with_empty_home(&guard);
654        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
655        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
656
657        assert!(load_credentials_with_instance(None).is_err());
658    }
659
660    #[test]
661    fn load_credentials_honours_instance_override_env() {
662        // The global `--instance` flag (exported as OMNI_DEV_ATLASSIAN_INSTANCE)
663        // overrides the instance for every command, even with no configured
664        // ATLASSIAN_INSTANCE_URL (#1117). A blank value is ignored.
665        let guard = EnvGuard::take();
666        let _dir = with_empty_home(&guard);
667        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
668        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
669
670        // Blank → ignored → falls back to the (absent) configured instance.
671        std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, "  ");
672        assert!(load_credentials().is_err());
673
674        // Set → used verbatim (trailing slash normalized).
675        std::env::set_var(
676            ATLASSIAN_INSTANCE_OVERRIDE_ENV,
677            "https://flag.atlassian.net/",
678        );
679        let creds = load_credentials().unwrap();
680        assert_eq!(creds.instance_url, "https://flag.atlassian.net");
681        assert_eq!(creds.email, "person@example.com");
682    }
683}