1use 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
15pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
17
18pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
20
21pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
23
24#[derive(Debug, Clone)]
26pub struct AtlassianCredentials {
27 pub instance_url: String,
29
30 pub email: String,
32
33 pub api_token: Secret,
35}
36
37pub fn load_credentials() -> Result<AtlassianCredentials> {
41 load_credentials_with_instance(None)
42}
43
44pub fn load_credentials_with_instance(
53 instance_override: Option<&str>,
54) -> Result<AtlassianCredentials> {
55 let settings = Settings::load().unwrap_or_default();
56
57 let instance_url = match instance_override {
58 Some(url) => url.to_string(),
59 None => settings
60 .get_env_var(ATLASSIAN_INSTANCE_URL)
61 .ok_or(AtlassianError::CredentialsNotFound)?,
62 };
63 let email = settings
64 .get_env_var(ATLASSIAN_EMAIL)
65 .ok_or(AtlassianError::CredentialsNotFound)?;
66 let api_token = settings
67 .get_env_var(ATLASSIAN_API_TOKEN)
68 .ok_or(AtlassianError::CredentialsNotFound)?;
69
70 let instance_url = instance_url.trim_end_matches('/').to_string();
72
73 Ok(AtlassianCredentials {
74 instance_url,
75 email,
76 api_token: api_token.into(),
77 })
78}
79
80#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
85pub struct AtlassianScopeStatus {
86 pub name: String,
89 pub has_email: bool,
91 pub has_token: bool,
93 #[serde(skip_serializing_if = "Option::is_none")]
97 pub instance_url: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
102pub struct AuthStatus {
103 pub scopes: Vec<AtlassianScopeStatus>,
106}
107
108pub fn status() -> AuthStatus {
116 let settings = Settings::load().unwrap_or_default();
117
118 let instance_url = settings
119 .get_env_var(ATLASSIAN_INSTANCE_URL)
120 .map(|v| v.trim_end_matches('/').to_string());
121 let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
122 let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
123
124 AuthStatus {
125 scopes: vec![AtlassianScopeStatus {
126 name: "default".to_string(),
127 has_email,
128 has_token,
129 instance_url,
130 }],
131 }
132}
133
134pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
140 save_credentials_to(
141 &Settings::get_settings_path()?,
142 active_profile_from(&SystemEnv).as_deref(),
143 credentials,
144 )
145}
146
147pub(crate) fn save_credentials_to(
153 settings_path: &std::path::Path,
154 profile: Option<&str>,
155 credentials: &AtlassianCredentials,
156) -> Result<()> {
157 Settings::upsert_env_vars_in(
158 settings_path,
159 profile,
160 &[
161 (ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str()),
162 (ATLASSIAN_EMAIL, credentials.email.as_str()),
163 (ATLASSIAN_API_TOKEN, credentials.api_token.expose_secret()),
164 ],
165 )
166}
167
168#[cfg(test)]
174#[allow(clippy::unwrap_used, clippy::expect_used)]
175pub(crate) mod test_util {
176 use super::{ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL};
177 use crate::utils::settings::PROFILE_ENV_VAR;
178
179 pub(crate) static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
183
184 pub(crate) struct EnvGuard {
190 _lock: std::sync::MutexGuard<'static, ()>,
191 snapshot: Vec<(&'static str, Option<String>)>,
192 }
193
194 impl EnvGuard {
195 pub(crate) fn take() -> Self {
196 let lock = AUTH_ENV_MUTEX
197 .lock()
198 .unwrap_or_else(std::sync::PoisonError::into_inner);
199 let keys = [
200 "HOME",
201 PROFILE_ENV_VAR,
202 ATLASSIAN_INSTANCE_URL,
203 ATLASSIAN_EMAIL,
204 ATLASSIAN_API_TOKEN,
205 ];
206 let snapshot = keys
207 .into_iter()
208 .map(|k| (k, std::env::var(k).ok()))
209 .collect();
210 Self {
211 _lock: lock,
212 snapshot,
213 }
214 }
215
216 pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
223 let dir = {
224 std::fs::create_dir_all("tmp").ok();
225 tempfile::TempDir::new_in("tmp").unwrap()
226 };
227 std::env::set_var("HOME", dir.path());
228 std::env::remove_var(PROFILE_ENV_VAR);
229 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
230 std::env::remove_var(ATLASSIAN_EMAIL);
231 std::env::remove_var(ATLASSIAN_API_TOKEN);
232 dir
233 }
234
235 pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
240 let dir = {
241 std::fs::create_dir_all("tmp").ok();
242 tempfile::TempDir::new_in("tmp").unwrap()
243 };
244 std::env::set_var("HOME", dir.path());
245 std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
246 std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
247 std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
248 dir
249 }
250 }
251
252 impl Drop for EnvGuard {
253 fn drop(&mut self) {
254 for (k, v) in &self.snapshot {
255 match v {
256 Some(val) => std::env::set_var(k, val),
257 None => std::env::remove_var(k),
258 }
259 }
260 }
261 }
262}
263
264#[cfg(test)]
265#[allow(clippy::unwrap_used, clippy::expect_used)]
266mod tests {
267 use std::fs;
268
269 use super::*;
270
271 #[test]
272 fn save_and_read_credentials() {
273 let temp_dir = {
274 std::fs::create_dir_all("tmp").ok();
275 tempfile::TempDir::new_in("tmp").unwrap()
276 };
277 let settings_path = temp_dir.path().join("settings.json");
278
279 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
281 fs::write(&settings_path, existing).unwrap();
282
283 let content = fs::read_to_string(&settings_path).unwrap();
285 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
286 val["env"]["ATLASSIAN_INSTANCE_URL"] =
287 serde_json::Value::String("https://test.atlassian.net".to_string());
288 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
289 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
290 let formatted = serde_json::to_string_pretty(&val).unwrap();
291 fs::write(&settings_path, formatted).unwrap();
292
293 let content = fs::read_to_string(&settings_path).unwrap();
295 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
296 assert_eq!(val["env"]["SOME_KEY"], "value");
297 assert_eq!(
298 val["env"]["ATLASSIAN_INSTANCE_URL"],
299 "https://test.atlassian.net"
300 );
301 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
302 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
303 }
304
305 #[test]
306 fn load_credentials_normalizes_trailing_slash() {
307 let url = "https://env.atlassian.net/";
309 let normalized = url.trim_end_matches('/').to_string();
310 assert_eq!(normalized, "https://env.atlassian.net");
311 }
312
313 #[test]
314 fn constant_key_names() {
315 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
316 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
317 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
318 }
319
320 #[test]
321 fn credentials_struct_clone_and_debug() {
322 let creds = AtlassianCredentials {
323 instance_url: "https://org.atlassian.net".to_string(),
324 email: "user@test.com".to_string(),
325 api_token: "super-sekret-api-token-value".into(),
326 };
327 let cloned = creds.clone();
328 assert_eq!(cloned.instance_url, creds.instance_url);
329 assert_eq!(cloned.email, creds.email);
330 assert_eq!(cloned.api_token, creds.api_token);
331 let debug = format!("{creds:?}");
333 assert!(debug.contains("AtlassianCredentials"));
334 assert!(
335 !debug.contains("super-sekret-api-token-value"),
336 "leaked token: {debug}"
337 );
338 assert!(debug.contains("api_token: <redacted>"));
339 }
340
341 use super::test_util::EnvGuard;
342
343 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
344 let dir = {
345 std::fs::create_dir_all("tmp").ok();
346 tempfile::TempDir::new_in("tmp").unwrap()
347 };
348 std::env::set_var("HOME", dir.path());
349 std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
350 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
351 std::env::remove_var(ATLASSIAN_EMAIL);
352 std::env::remove_var(ATLASSIAN_API_TOKEN);
353 dir
354 }
355
356 #[test]
357 fn status_reports_all_false_when_nothing_configured() {
358 let guard = EnvGuard::take();
359 let _dir = with_empty_home(&guard);
360
361 let status = status();
362 assert_eq!(status.scopes.len(), 1);
363 let scope = &status.scopes[0];
364 assert_eq!(scope.name, "default");
365 assert!(!scope.has_email);
366 assert!(!scope.has_token);
367 assert_eq!(scope.instance_url, None);
368 }
369
370 #[test]
371 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
372 let guard = EnvGuard::take();
373 let dir = with_empty_home(&guard);
374 let omni_dir = dir.path().join(".omni-dev");
375 fs::create_dir_all(&omni_dir).unwrap();
376 fs::write(
377 omni_dir.join("settings.json"),
378 r#"{"env":{
379 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
380 "ATLASSIAN_EMAIL":"person@example.com",
381 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
382 }}"#,
383 )
384 .unwrap();
385
386 let status = status();
387 assert_eq!(status.scopes.len(), 1);
388 let scope = &status.scopes[0];
389 assert!(scope.has_email);
390 assert!(scope.has_token);
391 assert_eq!(
392 scope.instance_url.as_deref(),
393 Some("https://status.atlassian.net")
394 );
395
396 let yaml = serde_yaml::to_string(&status).unwrap();
397 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
398 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
399 }
400
401 #[test]
402 fn status_returns_instance_url_from_env_without_trailing_slash() {
403 let guard = EnvGuard::take();
404 let _dir = with_empty_home(&guard);
405 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
406
407 let status = status();
408 let scope = &status.scopes[0];
409 assert_eq!(
410 scope.instance_url.as_deref(),
411 Some("https://env.atlassian.net")
412 );
413 assert!(!scope.has_email);
414 assert!(!scope.has_token);
415 }
416
417 #[test]
422 fn save_credentials_resolves_default_settings_path() {
423 let guard = EnvGuard::take();
424 let dir = with_empty_home(&guard);
425
426 let creds = AtlassianCredentials {
427 instance_url: "https://wrapper.atlassian.net".to_string(),
428 email: "wrapper@example.com".to_string(),
429 api_token: "wrapper-token".into(),
430 };
431 save_credentials(&creds).unwrap();
432
433 let settings_path = dir.path().join(".omni-dev").join("settings.json");
434 let val: serde_json::Value =
435 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
436 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
437 }
438
439 #[test]
443 fn save_credentials_creates_and_preserves() {
444 {
446 let temp_dir = {
447 std::fs::create_dir_all("tmp").ok();
448 tempfile::TempDir::new_in("tmp").unwrap()
449 };
450 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
451
452 let creds = AtlassianCredentials {
453 instance_url: "https://save.atlassian.net".to_string(),
454 email: "save@example.com".to_string(),
455 api_token: "save-token".into(),
456 };
457 save_credentials_to(&settings_path, None, &creds).unwrap();
458
459 assert!(settings_path.exists());
460 let content = fs::read_to_string(&settings_path).unwrap();
461 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
462 assert_eq!(
463 val["env"]["ATLASSIAN_INSTANCE_URL"],
464 "https://save.atlassian.net"
465 );
466 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
467 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
468
469 #[cfg(unix)]
471 {
472 use std::os::unix::fs::PermissionsExt;
473 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
474 assert_eq!(mode & 0o777, 0o600);
475 }
476 }
477
478 {
480 let temp_dir = {
481 std::fs::create_dir_all("tmp").ok();
482 tempfile::TempDir::new_in("tmp").unwrap()
483 };
484 let omni_dir = temp_dir.path().join(".omni-dev");
485 fs::create_dir_all(&omni_dir).unwrap();
486 let settings_path = omni_dir.join("settings.json");
487 fs::write(
488 &settings_path,
489 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
490 )
491 .unwrap();
492
493 let creds = AtlassianCredentials {
494 instance_url: "https://org.atlassian.net".to_string(),
495 email: "user@test.com".to_string(),
496 api_token: "token".into(),
497 };
498 save_credentials_to(&settings_path, None, &creds).unwrap();
499
500 let content = fs::read_to_string(&settings_path).unwrap();
501 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
502 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
503 assert_eq!(val["extra"], true);
504 assert_eq!(
505 val["env"]["ATLASSIAN_INSTANCE_URL"],
506 "https://org.atlassian.net"
507 );
508 }
509 }
510
511 #[test]
515 fn save_credentials_to_profile_writes_into_profile_env() {
516 let temp_dir = {
517 std::fs::create_dir_all("tmp").ok();
518 tempfile::TempDir::new_in("tmp").unwrap()
519 };
520 let omni_dir = temp_dir.path().join(".omni-dev");
521 fs::create_dir_all(&omni_dir).unwrap();
522 let settings_path = omni_dir.join("settings.json");
523 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
524
525 let creds = AtlassianCredentials {
526 instance_url: "https://work.atlassian.net".to_string(),
527 email: "work@example.com".to_string(),
528 api_token: "work-token".into(),
529 };
530 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
531
532 let val: serde_json::Value =
533 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
534 assert_eq!(
535 val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
536 "work@example.com"
537 );
538 assert_eq!(
539 val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
540 "https://work.atlassian.net"
541 );
542 assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
543 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
544 }
545
546 #[test]
547 fn load_credentials_with_instance_override_supplies_instance_url() {
548 let guard = EnvGuard::take();
552 let _dir = with_empty_home(&guard);
553 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
554 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
555
556 let creds =
557 load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
558 assert_eq!(creds.instance_url, "https://override.atlassian.net");
559 assert_eq!(creds.email, "person@example.com");
560 assert_eq!(creds.api_token.expose_secret(), "token");
561 }
562
563 #[test]
564 fn load_credentials_with_instance_none_requires_env_instance() {
565 let guard = EnvGuard::take();
568 let _dir = with_empty_home(&guard);
569 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
570 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
571
572 assert!(load_credentials_with_instance(None).is_err());
573 }
574}