#![allow(clippy::unwrap_used)]
use assert_cmd::Command;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn release_body(tag: &str) -> serde_json::Value {
serde_json::json!({ "tag_name": tag, "assets": [] })
}
fn bb(api: &str) -> (Command, tempfile::TempDir) {
let cfg = tempfile::tempdir().unwrap();
let mut cmd = Command::cargo_bin("bb").unwrap();
cmd.env("HOME", cfg.path())
.env("XDG_CONFIG_HOME", cfg.path())
.env("BB_UPDATE_API_BASE", api)
.env("BB_KEYRING_DISABLE", "1")
.env("NO_COLOR", "1");
(cmd, cfg)
}
#[tokio::test]
async fn reports_up_to_date_in_json_when_the_latest_tag_matches() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(release_body(&format!("v{}", env!("CARGO_PKG_VERSION")))),
)
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout is not pure json: {e}\n{stdout}"));
assert_eq!(parsed["up_to_date"], serde_json::Value::Bool(true));
assert_eq!(parsed["current"], env!("CARGO_PKG_VERSION"));
assert_eq!(parsed["latest"], format!("v{}", env!("CARGO_PKG_VERSION")));
}
#[tokio::test]
async fn the_api_token_is_never_sent_to_the_release_host() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(release_body("v0.0.1")))
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
cmd.args(["update", "--json"])
.env("BB_EMAIL", "dev@example.com")
.env("BB_TOKEN", "ATATT-super-secret-value")
.output()
.unwrap();
for request in server.received_requests().await.unwrap() {
assert!(
request.headers.get("authorization").is_none(),
"update sent an Authorization header to the release host"
);
let serialized = format!("{:?}", request.headers);
assert!(
!serialized.contains("ATATT-super-secret-value"),
"the api token leaked into a request header: {serialized}"
);
}
}
#[tokio::test]
async fn a_newer_release_with_missing_assets_fails_without_touching_the_binary() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(release_body("v99.0.0")))
.mount(&server)
.await;
let exe = assert_cmd::cargo::cargo_bin("bb");
let before = std::fs::read(&exe).unwrap();
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(!output.status.success(), "a failed update must not exit 0");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("missing") || stderr.contains("asset"),
"the error should name the missing asset, got: {stderr}"
);
assert_eq!(
std::fs::read(&exe).unwrap(),
before,
"the running binary was modified despite the update failing"
);
}
fn build_link_archive(entry_type: tar::EntryType, target: &str) -> Vec<u8> {
use std::io::Write;
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_entry_type(entry_type);
header.set_mode(0o644);
builder.append_link(&mut header, "bb", target).unwrap();
builder.finish().unwrap();
}
let mut archive_bytes = Vec::new();
{
let mut encoder =
flate2::write::GzEncoder::new(&mut archive_bytes, flate2::Compression::default());
encoder.write_all(&tar_bytes).unwrap();
encoder.finish().unwrap();
}
archive_bytes
}
async fn assert_link_entry_is_rejected(entry_type: tar::EntryType) {
use sha2::{Digest, Sha256};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let victim = tmp.path().join("victim");
std::fs::write(&victim, b"do not touch me").unwrap();
#[cfg(unix)]
std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
let archive_bytes = build_link_archive(entry_type, victim.to_str().unwrap());
let digest = format!("{:x}", Sha256::digest(&archive_bytes));
let triple = bb_cli::commands::update::current_triple().unwrap();
let tag = "v99.0.0";
let (archive_name, checksum_name) = bb_cli::commands::update::asset_names(tag, triple);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name": tag,
"assets": [
{
"name": archive_name,
"browser_download_url": format!("{}/assets/{archive_name}", server.uri()),
},
{
"name": checksum_name,
"browser_download_url": format!("{}/assets/{checksum_name}", server.uri()),
},
],
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/assets/{archive_name}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/assets/{checksum_name}")))
.respond_with(ResponseTemplate::new(200).set_body_string(digest))
.mount(&server)
.await;
let exe = assert_cmd::cargo::cargo_bin("bb");
let before = std::fs::read(&exe).unwrap();
let exe_dir = exe.parent().unwrap().to_path_buf();
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(
!output.status.success(),
"a rejected {entry_type:?} entry must not exit 0"
);
for entry in std::fs::read_dir(&exe_dir).unwrap() {
let name = entry.unwrap().file_name();
assert!(
!name.to_string_lossy().starts_with(".bb-update-staged"),
"a staged file was left behind: {name:?}"
);
}
let victim_meta = std::fs::symlink_metadata(&victim).unwrap();
assert!(
!victim_meta.file_type().is_symlink(),
"victim should still be a regular file"
);
#[cfg(unix)]
assert_eq!(
victim_meta.permissions().mode() & 0o777,
0o644,
"victim's permissions must be untouched"
);
assert_eq!(
std::fs::read(&victim).unwrap(),
b"do not touch me",
"victim's contents must be untouched"
);
assert_eq!(
std::fs::read(&exe).unwrap(),
before,
"the running binary was modified despite the {entry_type:?} rejection"
);
}
#[tokio::test]
async fn a_symlink_bb_entry_is_rejected_and_leaves_everything_untouched() {
assert_link_entry_is_rejected(tar::EntryType::Symlink).await;
}
#[tokio::test]
async fn a_hard_link_bb_entry_is_rejected_and_leaves_everything_untouched() {
assert_link_entry_is_rejected(tar::EntryType::Link).await;
}
#[tokio::test]
async fn rate_limited_403_with_reset_header_reports_retry_time() {
let server = MockServer::start().await;
let reset_epoch: i64 = 1_786_452_151;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("x-ratelimit-remaining", "0")
.insert_header("x-ratelimit-reset", reset_epoch.to_string().as_str()),
)
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(
!output.status.success(),
"a rate-limited update must not exit 0"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("rate limit"),
"stderr should name the rate limit, got: {stderr}"
);
let expected_time = chrono::DateTime::from_timestamp(reset_epoch, 0)
.unwrap()
.with_timezone(&chrono::Local)
.format("%H:%M")
.to_string();
assert!(
stderr.contains(&expected_time),
"stderr should contain the retry time {expected_time}, got: {stderr}"
);
}
#[tokio::test]
async fn rate_limited_403_without_reset_header_omits_the_time_without_panicking() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(403).insert_header("x-ratelimit-remaining", "0"))
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("rate limit"),
"stderr should still name the rate limit, got: {stderr}"
);
assert!(
!stderr.contains("1970"),
"stderr must never show a 1970 fallback timestamp, got: {stderr}"
);
}
#[tokio::test]
async fn non_rate_limit_403_reports_a_plain_release_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("release api error 403"),
"stderr should name the release api error, got: {stderr}"
);
assert!(
!stderr.contains("rate limit"),
"a plain 403 must not falsely claim a rate limit, got: {stderr}"
);
}
#[tokio::test]
async fn server_error_500_reports_release_api_error_without_blaming_bitbucket() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("release api error 500"),
"stderr should say release api error 500, got: {stderr}"
);
assert!(
!stderr.to_lowercase().contains("bitbucket"),
"stderr must never blame bitbucket for a release-api failure, got: {stderr}"
);
}
#[tokio::test]
async fn a_malformed_remote_tag_is_not_an_upgrade() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(release_body("nightly")))
.mount(&server)
.await;
let (mut cmd, _cfg) = bb(&server.uri());
let output = cmd.args(["update", "--json"]).output().unwrap();
assert!(output.status.success(), "should exit 0, not panic");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["up_to_date"], serde_json::Value::Bool(true));
}
#[tokio::test]
async fn update_never_touches_the_real_config_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/biokraft/bbcloud/releases/latest"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(release_body(&format!("v{}", env!("CARGO_PKG_VERSION")))),
)
.mount(&server)
.await;
let stand_in_for_real_home = tempfile::tempdir().unwrap();
let stand_in_real_state = stand_in_for_real_home
.path()
.join(".config")
.join("bb")
.join("skills.json");
let (mut cmd, cfg) = bb(&server.uri());
cmd.arg("update").assert().success();
assert!(
!stand_in_real_state.exists(),
"bb update must never write outside the HOME/XDG_CONFIG_HOME override: {} was created",
stand_in_real_state.display()
);
assert!(
cfg.path().join("bb").join("skills.json").exists(),
"the overridden config dir should be the one bb actually used"
);
}