use anyhow::Result;
use serde::Serialize;
use crate::atlassian::error::AtlassianError;
use crate::utils::env::{EnvSource, SystemEnv};
use crate::utils::secret::Secret;
use crate::utils::secret_env::{secret_var, secret_var_is_set};
use crate::utils::settings::{active_profile_from, Settings};
pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
pub const ATLASSIAN_INSTANCE_OVERRIDE_ENV: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
pub const ATLASSIAN_PAT: &str = "ATLASSIAN_PAT";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AtlassianAuth {
Basic {
email: String,
api_token: Secret,
},
Bearer {
token: Secret,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
#[default]
Basic,
Bearer,
}
impl AtlassianAuth {
#[must_use]
pub const fn mode(&self) -> AuthMode {
match self {
Self::Basic { .. } => AuthMode::Basic,
Self::Bearer { .. } => AuthMode::Bearer,
}
}
pub(crate) fn validate(&self) -> Result<()> {
let token = match self {
Self::Basic { email, api_token } => {
if email.trim().is_empty() {
anyhow::bail!("ATLASSIAN_EMAIL is required for Basic authentication");
}
api_token.expose_secret()
}
Self::Bearer { token } => token.expose_secret(),
};
if token.trim().is_empty() {
anyhow::bail!("Atlassian token must not be blank");
}
if token.chars().any(char::is_control) {
anyhow::bail!("Atlassian token contains invalid control characters");
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AtlassianCredentials {
pub instance_url: String,
pub auth: AtlassianAuth,
}
pub fn load_credentials() -> Result<AtlassianCredentials> {
let env_override = std::env::var(ATLASSIAN_INSTANCE_OVERRIDE_ENV)
.ok()
.filter(|s| !s.trim().is_empty());
load_credentials_with_instance(env_override.as_deref())
}
pub fn load_credentials_with_instance(
instance_override: Option<&str>,
) -> Result<AtlassianCredentials> {
let settings = Settings::load_or_warn_default();
load_credentials_from(&settings.env_source(), instance_override)
}
pub(crate) fn load_credentials_from(
env: &impl EnvSource,
instance_override: Option<&str>,
) -> Result<AtlassianCredentials> {
let scope = scope_status_from(env);
if scope.configuration_state == ConfigurationState::Conflict {
anyhow::bail!(
"ATLASSIAN_PAT and ATLASSIAN_API_TOKEN conflict; configure only one token source"
);
}
let instance_url = instance_override
.map(str::to_owned)
.or(scope.instance_url)
.filter(|v| !v.trim().is_empty())
.ok_or(AtlassianError::CredentialsNotFound)?;
let auth = if scope.has_pat {
AtlassianAuth::Bearer {
token: secret_var(env, ATLASSIAN_PAT)?.ok_or(AtlassianError::CredentialsNotFound)?,
}
} else {
AtlassianAuth::Basic {
email: env
.var(ATLASSIAN_EMAIL)
.filter(|v| !v.trim().is_empty())
.ok_or(AtlassianError::CredentialsNotFound)?,
api_token: secret_var(env, ATLASSIAN_API_TOKEN)?
.ok_or(AtlassianError::CredentialsNotFound)?,
}
};
auth.validate()?;
Ok(AtlassianCredentials {
instance_url: instance_url.trim_end_matches('/').to_string(),
auth,
})
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigurationState {
Missing,
Incomplete,
Configured,
Conflict,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AtlassianScopeStatus {
pub name: String,
pub has_email: bool,
pub has_token: bool,
pub has_pat: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<AuthMode>,
pub configuration_state: ConfigurationState,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AuthStatus {
pub scopes: Vec<AtlassianScopeStatus>,
}
pub fn status() -> AuthStatus {
let settings = Settings::load_or_warn_default();
status_from(&settings.env_source())
}
pub(crate) fn status_from(env: &impl EnvSource) -> AuthStatus {
AuthStatus {
scopes: vec![scope_status_from(env)],
}
}
fn scope_status_from(env: &impl EnvSource) -> AtlassianScopeStatus {
let instance_url = env
.var(ATLASSIAN_INSTANCE_URL)
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim_end_matches('/').to_string());
let has_email = env
.var(ATLASSIAN_EMAIL)
.is_some_and(|v| !v.trim().is_empty());
let has_token = secret_var_is_set(env, ATLASSIAN_API_TOKEN);
let has_pat = secret_var_is_set(env, ATLASSIAN_PAT);
let auth_mode = match (has_token, has_pat) {
(true, false) => Some(AuthMode::Basic),
(false, true) => Some(AuthMode::Bearer),
_ => None,
};
let configuration_state = if has_token && has_pat {
ConfigurationState::Conflict
} else if (has_pat || (has_token && has_email)) && instance_url.is_some() {
ConfigurationState::Configured
} else if !has_email && !has_token && !has_pat && instance_url.is_none() {
ConfigurationState::Missing
} else {
ConfigurationState::Incomplete
};
AtlassianScopeStatus {
name: "default".to_string(),
has_email,
has_token,
has_pat,
auth_mode,
configuration_state,
instance_url,
}
}
pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
save_credentials_to(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
credentials,
)
}
pub(crate) fn save_credentials_to(
settings_path: &std::path::Path,
profile: Option<&str>,
credentials: &AtlassianCredentials,
) -> Result<()> {
credentials.auth.validate()?;
if credentials.instance_url.trim().is_empty() {
anyhow::bail!("Instance URL is required");
}
let mut vars = vec![(ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str())];
let remove = match &credentials.auth {
AtlassianAuth::Basic { email, api_token } => {
vars.extend([
(ATLASSIAN_EMAIL, email.as_str()),
(ATLASSIAN_API_TOKEN, api_token.expose_secret()),
]);
vec![ATLASSIAN_PAT]
}
AtlassianAuth::Bearer { token } => {
vars.push((ATLASSIAN_PAT, token.expose_secret()));
vec![ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN]
}
};
Settings::replace_env_vars_in(settings_path, profile, &vars, &remove)
}
pub fn remove_credentials() -> Result<bool> {
remove_credentials_at(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
)
}
pub(crate) fn remove_credentials_at(
settings_path: &std::path::Path,
profile: Option<&str>,
) -> Result<bool> {
Settings::remove_env_vars_in(
settings_path,
profile,
&[
ATLASSIAN_INSTANCE_URL,
ATLASSIAN_EMAIL,
ATLASSIAN_API_TOKEN,
ATLASSIAN_PAT,
],
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
pub(crate) mod test_util {
use super::{
ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_OVERRIDE_ENV,
ATLASSIAN_INSTANCE_URL, ATLASSIAN_PAT,
};
use crate::utils::settings::PROFILE_ENV_VAR;
pub(crate) static AUTH_ENV_MUTEX: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
pub(crate) struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
snapshot: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
pub(crate) fn take() -> Self {
let lock = AUTH_ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let keys = [
"HOME",
PROFILE_ENV_VAR,
ATLASSIAN_INSTANCE_URL,
ATLASSIAN_INSTANCE_OVERRIDE_ENV,
ATLASSIAN_EMAIL,
ATLASSIAN_API_TOKEN,
ATLASSIAN_PAT,
];
let snapshot = keys
.into_iter()
.map(|k| (k, std::env::var(k).ok()))
.collect();
Self {
_lock: lock,
snapshot,
}
}
pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::remove_var(PROFILE_ENV_VAR);
std::env::remove_var(ATLASSIAN_INSTANCE_URL);
std::env::remove_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV);
std::env::remove_var(ATLASSIAN_EMAIL);
std::env::remove_var(ATLASSIAN_API_TOKEN);
std::env::remove_var(ATLASSIAN_PAT);
dir
}
pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
std::env::remove_var(ATLASSIAN_PAT);
dir
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.snapshot {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::fs;
use super::*;
#[test]
fn save_and_read_credentials() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join("settings.json");
let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
fs::write(&settings_path, existing).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
val["env"]["ATLASSIAN_INSTANCE_URL"] =
serde_json::Value::String("https://test.atlassian.net".to_string());
val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
let formatted = serde_json::to_string_pretty(&val).unwrap();
fs::write(&settings_path, formatted).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(val["env"]["SOME_KEY"], "value");
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://test.atlassian.net"
);
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
}
#[test]
fn load_credentials_normalizes_trailing_slash() {
let url = "https://env.atlassian.net/";
let normalized = url.trim_end_matches('/').to_string();
assert_eq!(normalized, "https://env.atlassian.net");
}
#[test]
fn constant_key_names() {
assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
}
#[test]
fn credentials_struct_clone_and_debug() {
let creds = AtlassianCredentials {
instance_url: "https://org.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "user@test.com".to_string(),
api_token: "super-sekret-api-token-value".into(),
},
};
let cloned = creds.clone();
assert_eq!(cloned.instance_url, creds.instance_url);
assert_eq!(cloned.auth, creds.auth);
let debug = format!("{creds:?}");
assert!(debug.contains("AtlassianCredentials"));
assert!(
!debug.contains("super-sekret-api-token-value"),
"leaked token: {debug}"
);
assert!(debug.contains("api_token: <redacted>"));
}
use super::test_util::EnvGuard;
fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
std::env::remove_var(ATLASSIAN_INSTANCE_URL);
std::env::remove_var(ATLASSIAN_EMAIL);
std::env::remove_var(ATLASSIAN_API_TOKEN);
std::env::remove_var(ATLASSIAN_PAT);
dir
}
#[test]
fn status_reports_all_false_when_nothing_configured() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
let status = status();
assert_eq!(status.scopes.len(), 1);
let scope = &status.scopes[0];
assert_eq!(scope.name, "default");
assert!(!scope.has_email);
assert!(!scope.has_token);
assert_eq!(scope.instance_url, None);
}
#[test]
fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
let guard = EnvGuard::take();
let dir = with_empty_home(&guard);
let omni_dir = dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
fs::write(
omni_dir.join("settings.json"),
r#"{"env":{
"ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
"ATLASSIAN_EMAIL":"person@example.com",
"ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
}}"#,
)
.unwrap();
let status = status();
assert_eq!(status.scopes.len(), 1);
let scope = &status.scopes[0];
assert!(scope.has_email);
assert!(scope.has_token);
assert_eq!(
scope.instance_url.as_deref(),
Some("https://status.atlassian.net")
);
let yaml = serde_yaml::to_string(&status).unwrap();
assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
}
#[test]
fn status_returns_instance_url_from_env_without_trailing_slash() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
let status = status();
let scope = &status.scopes[0];
assert_eq!(
scope.instance_url.as_deref(),
Some("https://env.atlassian.net")
);
assert!(!scope.has_email);
assert!(!scope.has_token);
}
#[test]
fn save_credentials_resolves_default_settings_path() {
let guard = EnvGuard::take();
let dir = with_empty_home(&guard);
let creds = AtlassianCredentials {
instance_url: "https://wrapper.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "wrapper@example.com".to_string(),
api_token: "wrapper-token".into(),
},
};
save_credentials(&creds).unwrap();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
}
#[test]
fn remove_credentials_resolves_default_settings_path() {
let guard = EnvGuard::take();
let dir = with_empty_home(&guard);
let creds = AtlassianCredentials {
instance_url: "https://wrapper.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "wrapper@example.com".to_string(),
api_token: "wrapper-token".into(),
},
};
save_credentials(&creds).unwrap();
assert!(remove_credentials().unwrap());
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
assert!(!remove_credentials().unwrap());
}
#[test]
fn save_credentials_creates_and_preserves() {
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
let creds = AtlassianCredentials {
instance_url: "https://save.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "save@example.com".to_string(),
api_token: "save-token".into(),
},
};
save_credentials_to(&settings_path, None, &creds).unwrap();
assert!(settings_path.exists());
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://save.atlassian.net"
);
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(
&settings_path,
r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
)
.unwrap();
let creds = AtlassianCredentials {
instance_url: "https://org.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "user@test.com".to_string(),
api_token: "token".into(),
},
};
save_credentials_to(&settings_path, None, &creds).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
assert_eq!(val["extra"], true);
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://org.atlassian.net"
);
}
}
#[test]
fn save_credentials_to_profile_writes_into_profile_env() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
let creds = AtlassianCredentials {
instance_url: "https://work.atlassian.net".to_string(),
auth: crate::atlassian::auth::AtlassianAuth::Basic {
email: "work@example.com".to_string(),
api_token: "work-token".into(),
},
};
save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
"work@example.com"
);
assert_eq!(
val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
"https://work.atlassian.net"
);
assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
}
#[test]
fn load_credentials_with_instance_override_supplies_instance_url() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "token");
let creds =
load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
assert_eq!(creds.instance_url, "https://override.atlassian.net");
assert!(
matches!(&creds.auth, AtlassianAuth::Basic { email, .. } if email == "person@example.com")
);
assert!(
matches!(&creds.auth, AtlassianAuth::Basic { api_token, .. } if api_token.expose_secret() == "token")
);
}
#[test]
fn load_credentials_with_instance_none_requires_env_instance() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "token");
assert!(load_credentials_with_instance(None).is_err());
}
#[test]
fn load_credentials_honours_instance_override_env() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "token");
std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, " ");
assert!(load_credentials().is_err());
std::env::set_var(
ATLASSIAN_INSTANCE_OVERRIDE_ENV,
"https://flag.atlassian.net/",
);
let creds = load_credentials().unwrap();
assert_eq!(creds.instance_url, "https://flag.atlassian.net");
assert!(
matches!(&creds.auth, AtlassianAuth::Basic { email, .. } if email == "person@example.com")
);
}
use crate::test_support::env::{secret_file, MapEnv};
fn pat_env() -> MapEnv {
MapEnv::new()
.with(ATLASSIAN_INSTANCE_URL, "https://self.example/jira/")
.with(ATLASSIAN_PAT, "private-pat")
}
#[test]
fn pat_loads_without_email_and_is_redacted() {
let creds = load_credentials_from(&pat_env(), None).unwrap();
assert_eq!(creds.instance_url, "https://self.example/jira");
assert_eq!(creds.auth.mode(), AuthMode::Bearer);
assert!(!format!("{creds:?}").contains("private-pat"));
let scope = scope_status_from(&pat_env().with(ATLASSIAN_EMAIL, "leftover@example.com"));
assert_eq!(scope.configuration_state, ConfigurationState::Configured);
assert_eq!(scope.auth_mode, Some(AuthMode::Bearer));
let yaml = serde_yaml::to_string(&scope).unwrap();
assert!(yaml.contains("has_pat: true"));
assert!(!yaml.contains("private-pat"));
assert!(!yaml.contains("leftover@example.com"));
}
#[test]
fn token_conflict_precedes_secret_resolution() {
let env = MapEnv::new()
.with(ATLASSIAN_INSTANCE_URL, "https://self.example")
.with("ATLASSIAN_PAT_COMMAND", "/does/not/exist")
.with("ATLASSIAN_API_TOKEN_FILE", "/does/not/exist");
let scope = scope_status_from(&env);
assert_eq!(scope.configuration_state, ConfigurationState::Conflict);
assert_eq!(scope.auth_mode, None);
let err = load_credentials_from(&env, None).unwrap_err().to_string();
assert!(err.contains("conflict"));
assert!(!err.contains("/does/not/exist"));
}
#[test]
fn presence_status_does_not_resolve_sources() {
for key in ["ATLASSIAN_PAT_FILE", "ATLASSIAN_PAT_COMMAND"] {
let env = MapEnv::new()
.with(ATLASSIAN_INSTANCE_URL, "https://self.example")
.with(key, "/does/not/exist");
assert_eq!(
scope_status_from(&env).configuration_state,
ConfigurationState::Configured
);
}
assert_eq!(
scope_status_from(&MapEnv::new()).configuration_state,
ConfigurationState::Missing
);
assert_eq!(
scope_status_from(&MapEnv::new().with(ATLASSIAN_PAT, "pat")).configuration_state,
ConfigurationState::Incomplete
);
}
#[test]
fn pat_file_and_helper_use_secret_resolver() {
let (_dir, path) = secret_file("file-pat\n");
let env = MapEnv::new().with("ATLASSIAN_PAT_FILE", &path);
let creds = load_credentials_from(&env, Some("https://self.example/confluence/")).unwrap();
assert!(
matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "file-pat")
);
#[cfg(unix)]
{
let env = MapEnv::new().with("ATLASSIAN_PAT_COMMAND", "/usr/bin/printf helper-pat");
let creds = load_credentials_from(&env, Some("https://self.example")).unwrap();
assert!(
matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "helper-pat")
);
}
}
#[test]
fn invalid_and_missing_credentials_do_not_fall_back() {
for token in [" ", "pat\r\nInjected: yes"] {
let env = MapEnv::new().with(ATLASSIAN_PAT, token);
let err = load_credentials_from(&env, Some("https://self.example")).unwrap_err();
if !token.trim().is_empty() {
assert!(!err.to_string().contains(token));
}
}
let env = MapEnv::new().with(ATLASSIAN_API_TOKEN, "cloud-token");
assert!(load_credentials_from(&env, Some("https://self.example")).is_err());
assert!(load_credentials_from(&MapEnv::new(), Some("https://self.example")).is_err());
assert!(load_credentials_from(&pat_env(), Some(" ")).is_err());
let env = MapEnv::new()
.with(ATLASSIAN_PAT, "pat")
.with("ATLASSIAN_PAT_FILE", "/missing");
assert!(load_credentials_from(&env, Some("https://self.example")).is_err());
}
struct Layered<'a> {
settings: &'a Settings,
raw: MapEnv,
profile: Option<&'a str>,
}
impl EnvSource for Layered<'_> {
fn var(&self, key: &str) -> Option<String> {
self.settings.resolve_with(&self.raw, self.profile, key)
}
fn var_triple(
&self,
key: &str,
file: &str,
command: &str,
) -> crate::utils::env::SecretTriple {
self.settings
.resolve_triple_with(&self.raw, self.profile, key, file, command)
}
}
#[test]
fn pat_profiles_are_isolated_and_process_source_wins() {
let settings: Settings = serde_json::from_value(serde_json::json!({
"env": {"ATLASSIAN_EMAIL": "base@example.com", "ATLASSIAN_API_TOKEN": "base-token"},
"profiles": {"work": {"env": {"ATLASSIAN_PAT": "profile-pat"}}}
}))
.unwrap();
let source = Layered {
settings: &settings,
raw: MapEnv::new(),
profile: Some("work"),
};
let creds = load_credentials_from(&source, Some("https://self.example")).unwrap();
assert!(
matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "profile-pat")
);
let source = Layered {
raw: MapEnv::new().with(ATLASSIAN_PAT, "process-pat"),
..source
};
let creds = load_credentials_from(&source, Some("https://self.example")).unwrap();
assert!(
matches!(creds.auth, AtlassianAuth::Bearer { token } if token.expose_secret() == "process-pat")
);
}
#[test]
fn mode_switch_and_logout_remove_obsolete_sources_atomically() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let initial = serde_json::json!({"unknown": true, "env": {"KEEP": "base"}, "profiles": {
"work": {"env": {"KEEP": "work", "ATLASSIAN_EMAIL": "old@example.com",
"ATLASSIAN_API_TOKEN_FILE": "/old-file", "ATLASSIAN_API_TOKEN_COMMAND": "old-helper"}},
"other": {"env": {"ATLASSIAN_PAT": "other-pat"}}
}});
fs::write(&path, initial.to_string()).unwrap();
let creds = AtlassianCredentials {
instance_url: "https://self.example".into(),
auth: AtlassianAuth::Bearer {
token: "new-pat".into(),
},
};
save_credentials_to(&path, Some("work"), &creds).unwrap();
let saved: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let env = &saved["profiles"]["work"]["env"];
assert_eq!(env[ATLASSIAN_PAT], "new-pat");
for key in [
ATLASSIAN_EMAIL,
ATLASSIAN_API_TOKEN,
"ATLASSIAN_API_TOKEN_FILE",
"ATLASSIAN_API_TOKEN_COMMAND",
] {
assert!(env.get(key).is_none());
}
assert_eq!(saved["env"], initial["env"]);
assert_eq!(saved["profiles"]["other"], initial["profiles"]["other"]);
assert_eq!(saved["unknown"], true);
let creds = AtlassianCredentials {
instance_url: "https://cloud.example".into(),
auth: AtlassianAuth::Basic {
email: "me@example.com".into(),
api_token: "cloud-token".into(),
},
};
save_credentials_to(&path, Some("work"), &creds).unwrap();
let saved: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(saved["profiles"]["work"]["env"]
.get(ATLASSIAN_PAT)
.is_none());
assert!(remove_credentials_at(&path, Some("work")).unwrap());
assert!(!remove_credentials_at(&path, Some("work")).unwrap());
}
#[test]
fn selected_helper_refusal_leaves_file_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let initial = r#"{"env":{"ATLASSIAN_PAT_COMMAND":"helper","ATLASSIAN_API_TOKEN":"old"}}"#;
fs::write(&path, initial).unwrap();
let creds = AtlassianCredentials {
instance_url: "https://self.example".into(),
auth: AtlassianAuth::Bearer {
token: "new-pat".into(),
},
};
assert!(save_credentials_to(&path, None, &creds).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), initial);
}
}