use super::*;
use std::sync::Arc;
use crate::command::CommandRunner;
use crate::command::test_support::RecordingCommandRunner;
use crate::filesystem::LocalFilesystem;
fn setup_publish_project(dir: &std::path::Path) -> ProjectInfo {
write_cargo_toml(
dir,
"[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
);
ProjectInfo::for_test("my-crate", AbsolutePath::new(dir.to_path_buf()).unwrap())
}
fn recording_adapter_with_env(dir: &std::path::Path, env: crate::Env) -> CargoAdapter {
CargoAdapter::new(
CargoConfig::default(),
crate::path::AbsolutePath::new(dir).unwrap(),
env,
)
}
fn env_with_auth_flags(
runner: &Arc<RecordingCommandRunner>,
token: bool,
oidc: bool,
) -> crate::Env {
crate::Env::new(
Arc::clone(runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::clone(runner) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
)
.with_cargo_registry_token_present(token)
.with_oidc_environment(oidc)
}
#[tokio::test]
async fn publish_success_returns_published() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
let result = adapter.publish(&info).await.unwrap();
assert_eq!(result, PublishOutcome::Published);
let invocations = runner.invocations();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].program, "cargo");
assert_eq!(invocations[0].args[0], "publish");
}
#[tokio::test]
async fn publish_already_uploaded_returns_already_published() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(
RecordingCommandRunner::new(1)
.with_stderr(b"error: crate version is already uploaded".to_vec()),
);
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
let result = adapter.publish(&info).await.unwrap();
assert_eq!(result, PublishOutcome::AlreadyPublished);
}
#[tokio::test]
async fn publish_already_exists_returns_already_published() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(
RecordingCommandRunner::new(1)
.with_stderr(b"error: package already exists on crates.io".to_vec()),
);
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
let result = adapter.publish(&info).await.unwrap();
assert_eq!(result, PublishOutcome::AlreadyPublished);
}
#[tokio::test]
async fn publish_other_failure_returns_error() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(
RecordingCommandRunner::new(1)
.with_stderr(b"error: network error connecting to crates.io".to_vec()),
);
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
let result = adapter.publish(&info).await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cargo publish failed"),
"Expected 'cargo publish failed', got: {msg}"
);
}
#[tokio::test]
async fn publish_passes_manifest_path_arg() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
adapter.publish(&info).await.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations.len(), 1);
assert!(
invocations[0].args.contains(&"--manifest-path".to_string()),
"Should pass --manifest-path, got: {:?}",
invocations[0].args
);
}
#[tokio::test]
async fn publish_without_cargo_token_still_executes_publish() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter =
recording_adapter_with_env(dir.path(), env_with_auth_flags(&runner, false, false));
let result = adapter.publish(&info).await.unwrap();
assert_eq!(result, PublishOutcome::Published);
assert_eq!(runner.invocations()[0].program, "cargo");
assert_eq!(runner.invocations()[0].args[0], "publish");
}
#[tokio::test]
async fn publish_without_cargo_token_no_oidc_emits_credential_warning() {
crate::test_logging::init_test_logger();
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter =
recording_adapter_with_env(dir.path(), env_with_auth_flags(&runner, false, false));
adapter.publish(&info).await.unwrap();
let logs = crate::test_logging::take_logs();
let warn_msgs: Vec<_> = logs
.iter()
.filter(|(lvl, _)| *lvl == log::Level::Warn)
.collect();
assert!(
warn_msgs.iter().any(|(_, msg)| msg.contains("cargo login")),
"Expected credential warning with cargo login hint, got: {warn_msgs:?}"
);
assert!(
!warn_msgs
.iter()
.any(|(_, msg)| msg.contains("crates-io-auth-action")),
"Should NOT emit trusted publishing hint in non-OIDC environment, got: {warn_msgs:?}"
);
}
#[tokio::test]
async fn publish_without_cargo_token_with_oidc_emits_trusted_publishing_hint() {
crate::test_logging::init_test_logger();
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter = recording_adapter_with_env(dir.path(), env_with_auth_flags(&runner, false, true));
adapter.publish(&info).await.unwrap();
let logs = crate::test_logging::take_logs();
let warn_msgs: Vec<_> = logs
.iter()
.filter(|(lvl, _)| *lvl == log::Level::Warn)
.collect();
assert!(
warn_msgs
.iter()
.any(|(_, msg)| msg.contains("crates-io-auth-action")),
"Expected trusted publishing hint with exchange action, got: {warn_msgs:?}"
);
assert!(
!warn_msgs.iter().any(|(_, msg)| msg.contains("cargo login")),
"Should NOT emit cargo login hint in OIDC environment, got: {warn_msgs:?}"
);
}
#[tokio::test]
async fn publish_with_cargo_token_no_oidc_no_warning() {
crate::test_logging::init_test_logger();
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter = recording_adapter_with_env(dir.path(), env_with_auth_flags(&runner, true, false));
adapter.publish(&info).await.unwrap();
let logs = crate::test_logging::take_logs();
assert!(
!logs.iter().any(|(lvl, _)| *lvl == log::Level::Warn),
"Should NOT emit any warning for token+no-OIDC (traditional flow)"
);
}
#[tokio::test]
async fn publish_with_cargo_token_with_oidc_no_warning() {
crate::test_logging::init_test_logger();
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let adapter = recording_adapter_with_env(dir.path(), env_with_auth_flags(&runner, true, true));
adapter.publish(&info).await.unwrap();
let logs = crate::test_logging::take_logs();
assert!(
!logs.iter().any(|(lvl, _)| *lvl == log::Level::Warn),
"Should NOT emit any warning for token+OIDC (trusted publishing happy path)"
);
}
#[tokio::test]
async fn publish_with_cargo_token_executes_publish() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(RecordingCommandRunner::new(0));
let env = crate::Env::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new("/tmp").unwrap(),
)),
)
.with_cargo_registry_token_present(true);
let adapter = recording_adapter_with_env(dir.path(), env);
let result = adapter.publish(&info).await.unwrap();
assert_eq!(result, PublishOutcome::Published);
assert_eq!(runner.invocations()[0].program, "cargo");
}
#[tokio::test]
async fn publish_failure_redacts_credentials_from_stderr() {
let dir = temp_dir();
let info = setup_publish_project(dir.path());
let runner = Arc::new(
RecordingCommandRunner::new(1).with_stderr(
b"error: failed to get https://user:SECRET_TOKEN@private.registry.example.com/: 403"
.to_vec(),
),
);
let adapter =
recording_adapter_inspectable(CargoConfig::default(), dir.path(), Arc::clone(&runner));
let err = adapter.publish(&info).await.unwrap_err().to_string();
assert!(
!err.contains("SECRET_TOKEN"),
"token must not appear in error: {err}"
);
assert!(err.contains("[REDACTED]"), "expected [REDACTED] in: {err}");
}
#[tokio::test]
async fn registry_name_is_crates_io() {
let dir = temp_dir();
let adapter = recording_adapter(CargoConfig::default(), dir.path(), 0);
assert_eq!(adapter.registry_name().await, "crates.io");
}
#[tokio::test]
async fn manifest_filename_is_cargo_toml() {
let dir = temp_dir();
let adapter = recording_adapter(CargoConfig::default(), dir.path(), 0);
assert_eq!(adapter.manifest_filename().await, "Cargo.toml");
}