1use 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
16pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
18
19pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
21
22pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
24
25pub const ATLASSIAN_INSTANCE_OVERRIDE_ENV: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
32
33pub const ATLASSIAN_PAT: &str = "ATLASSIAN_PAT";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum AtlassianAuth {
39 Basic {
41 email: String,
43 api_token: Secret,
45 },
46 Bearer {
48 token: Secret,
50 },
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum AuthMode {
57 #[default]
59 Basic,
60 Bearer,
62}
63
64impl AtlassianAuth {
65 #[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 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#[derive(Debug, Clone)]
97pub struct AtlassianCredentials {
98 pub instance_url: String,
100 pub auth: AtlassianAuth,
102}
103
104pub 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
118pub 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
134pub(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#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
173#[serde(rename_all = "snake_case")]
174pub enum ConfigurationState {
175 Missing,
177 Incomplete,
179 Configured,
181 Conflict,
183}
184
185#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
190pub struct AtlassianScopeStatus {
191 pub name: String,
194 pub has_email: bool,
196 pub has_token: bool,
198 pub has_pat: bool,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub auth_mode: Option<AuthMode>,
203 pub configuration_state: ConfigurationState,
205 #[serde(skip_serializing_if = "Option::is_none")]
209 pub instance_url: Option<String>,
210}
211
212#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
214pub struct AuthStatus {
215 pub scopes: Vec<AtlassianScopeStatus>,
218}
219
220pub fn status() -> AuthStatus {
228 let settings = Settings::load_or_warn_default();
229
230 status_from(&settings.env_source())
231}
232
233pub(crate) fn status_from(env: &impl EnvSource) -> AuthStatus {
235 AuthStatus {
236 scopes: vec![scope_status_from(env)],
237 }
238}
239
240fn 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
276pub 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
289pub(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
320pub fn remove_credentials() -> Result<bool> {
328 remove_credentials_at(
329 &Settings::get_settings_path()?,
330 active_profile_from(&SystemEnv).as_deref(),
331 )
332}
333
334pub(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#[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 pub(crate) static AUTH_ENV_MUTEX: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
380
381 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 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 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 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
483 fs::write(&settings_path, existing).unwrap();
484
485 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 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 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 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 #[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 #[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 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 assert!(!remove_credentials().unwrap());
673 }
674
675 #[test]
679 fn save_credentials_creates_and_preserves() {
680 {
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 #[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 {
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 #[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 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 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 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 std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, " ");
833 assert!(load_credentials().is_err());
834
835 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 #[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}