#![allow(clippy::unwrap_used)]
use assert_cmd::Command;
use predicates::str::contains;
use tempfile::tempdir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn auth_status_redacts_the_token() {
for args in [vec!["auth", "status"], vec!["auth", "status", "--json"]] {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(&args)
.env("BB_EMAIL", "dev@example.com")
.env("BB_TOKEN", "ATATT3xFfGF0_super_secret_value")
.env("BB_API_BASE", "http://127.0.0.1:1")
.output()
.unwrap();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!combined.contains("ATATT3xFfGF0"),
"token leaked for {args:?}: {combined}"
);
assert!(
!combined.contains("super_secret"),
"token body leaked for {args:?}: {combined}"
);
}
}
#[test]
fn auth_status_shows_email_and_redacted_tail() {
Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "status"])
.env("BB_EMAIL", "dev@example.com")
.env("BB_TOKEN", "ATATT3xFfGF0abcd")
.env("BB_API_BASE", "http://127.0.0.1:1")
.assert()
.stdout(contains("dev@example.com"))
.stdout(contains("****abcd"));
}
#[test]
fn auth_status_without_credentials_exits_two() {
Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "status"])
.env("BB_EMAIL", "")
.env("BB_TOKEN", "")
.env("BB_KEYRING_DISABLE", "1")
.assert()
.code(2)
.stderr(contains("bb auth login"));
}
#[test]
fn auth_logout_json_emits_parseable_json() {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "logout", "--json"])
.env("BB_KEYRING_DISABLE", "1")
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let _value: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("stdout did not parse as JSON: {e}\nstdout: {stdout}"));
}
#[test]
fn auth_login_non_tty_names_required_flags() {
let assert = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "login"])
.env("BB_API_BASE", "http://127.0.0.1:1")
.write_stdin("")
.assert()
.failure();
let output = assert.get_output();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(combined.contains("--email"), "missing --email: {combined}");
assert!(
combined.contains("--token-stdin"),
"missing --token-stdin: {combined}"
);
}
#[test]
fn auth_login_non_tty_never_leaks_secret() {
for args in [vec!["auth", "login"], vec!["auth", "login", "--json"]] {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(&args)
.env("BB_API_BASE", "http://127.0.0.1:1")
.write_stdin("super-secret-token-value")
.output()
.unwrap();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!combined.contains("super-secret-token-value"),
"secret leaked for {args:?}: {combined}"
);
}
}
#[test]
fn auth_help_mentions_api_token_not_app_password() {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "login", "--help"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
assert!(text.contains("api token"));
assert!(!text.contains("app password"));
}
#[tokio::test]
async fn login_verifies_the_token_and_reports_the_account() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"display_name": "Dev Person"})),
)
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args([
"auth",
"login",
"--email",
"dev@example.com",
"--token-stdin",
])
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("ATATT3xFfGF0abcd")
.output()
.unwrap();
assert!(out.status.success(), "login failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Dev Person"), "no account name: {stdout}");
assert!(stdout.contains("****abcd"), "no redacted tail: {stdout}");
assert!(
!stdout.contains("ATATT3xFfGF0abcd"),
"token leaked: {stdout}"
);
}
#[tokio::test]
async fn login_json_emits_pure_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"display_name": "Dev Person"})),
)
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args([
"auth",
"login",
"--email",
"dev@example.com",
"--token-stdin",
"--json",
])
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("ATATT3xFfGF0abcd")
.output()
.unwrap();
assert!(out.status.success(), "login failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
let value: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("stdout was not pure JSON: {e}\nstdout: {stdout}"));
assert_eq!(value["account"], "Dev Person");
assert_eq!(value["email"], "dev@example.com");
assert_eq!(value["token"], "****abcd");
}
#[test]
fn login_rejects_an_email_without_an_at_sign() {
Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "login", "--email", "not-an-email", "--token-stdin"])
.env("BB_API_BASE", "http://127.0.0.1:1")
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("some-token")
.assert()
.failure()
.stderr(contains("atlassian account email"));
}
#[test]
fn logout_warns_about_a_legacy_plaintext_credential_file() {
let home = tempdir().unwrap();
std::fs::write(
home.path().join(".bitbucket-rest-cli-config.json"),
"{\"token\":\"whatever\"}",
)
.unwrap();
Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "logout"])
.env("HOME", home.path())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.assert()
.success()
.stderr(contains("legacy plaintext credential file"));
}
#[tokio::test]
async fn status_reports_an_unverified_account_when_the_identity_check_fails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "status", "--json"])
.env("BB_EMAIL", "dev@example.com")
.env("BB_TOKEN", "ATATT3xFfGF0abcd")
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.output()
.unwrap();
assert!(out.status.success(), "status failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
let value: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert!(
value["account"].is_null(),
"expected a null account, got {value}"
);
}
#[test]
fn login_prints_the_token_url_and_every_required_scope() {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "login"])
.env("BB_API_BASE", "http://127.0.0.1:1")
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("")
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(1),
"expected the non-interactive config error: {out:?}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("https://id.atlassian.com/manage-profile/security/api-tokens"),
"no token url: {stdout}"
);
for (scope, _) in bb_cli::commands::auth::SCOPES {
assert!(stdout.contains(scope), "scope {scope} missing: {stdout}");
}
assert!(
stdout.contains("Create API token with scopes"),
"no scoped-token instruction: {stdout}"
);
}
#[tokio::test]
async fn login_with_both_values_supplied_prints_no_walkthrough() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"display_name": "Dev Person"})),
)
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args([
"auth",
"login",
"--email",
"dev@example.com",
"--token-stdin",
])
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("ATATT3xFfGF0abcd")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("Create API token with scopes"),
"walkthrough printed on the non-interactive path: {stdout}"
);
}
#[test]
fn auth_login_help_lists_the_required_scopes() {
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args(["auth", "login", "--help"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("id.atlassian.com"), "no token url: {text}");
for (scope, _) in bb_cli::commands::auth::SCOPES {
assert!(text.contains(scope), "scope {scope} missing: {text}");
}
}
#[tokio::test]
async fn login_names_the_missing_user_scope_on_a_403() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(
ResponseTemplate::new(403)
.set_body_json(serde_json::json!({"error": {"message": "Forbidden"}})),
)
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args([
"auth",
"login",
"--email",
"dev@example.com",
"--token-stdin",
])
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("ATATT3xFfGF0abcd")
.output()
.unwrap();
assert!(!out.status.success(), "403 must fail login: {out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("read:user:bitbucket"),
"no scope hint: {stderr}"
);
let combined = format!("{}{stderr}", String::from_utf8_lossy(&out.stdout));
assert!(
!combined.contains("ATATT3xFfGF0abcd"),
"token leaked: {combined}"
);
}
#[tokio::test]
async fn login_explains_a_rejected_email_and_token_pair() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let out = Command::cargo_bin("bb")
.unwrap()
.env("BB_NO_UPDATE_CHECK", "1")
.args([
"auth",
"login",
"--email",
"dev@example.com",
"--token-stdin",
])
.env("BB_API_BASE", server.uri())
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1")
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.write_stdin("ATATT3xFfGF0abcd")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "401 must exit 2: {out:?}");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("atlassian account email"),
"no email hint: {combined}"
);
assert!(
!combined.contains("ATATT3xFfGF0abcd"),
"token leaked: {combined}"
);
}
#[test]
fn the_readme_scope_table_matches_the_login_walkthrough() {
let readme = include_str!("../README.md");
let documented: Vec<&str> = readme
.lines()
.skip_while(|l| !l.starts_with("### Token scopes"))
.take_while(|l| !l.starts_with("### CI"))
.filter_map(|l| l.strip_prefix("| `"))
.filter_map(|rest| rest.split('`').next())
.collect();
assert!(
!documented.is_empty(),
"found no scope rows — the README's `### Token scopes` table moved or changed shape"
);
let printed: Vec<&str> = bb_cli::commands::auth::SCOPES
.iter()
.map(|(s, _)| *s)
.collect();
for scope in &documented {
assert!(
printed.contains(scope),
"README documents {scope} but `bb auth login` never tells anyone to grant it"
);
}
for scope in &printed {
assert!(
documented.contains(scope),
"`bb auth login` asks for {scope} but the README scope table does not list it"
);
}
}