use std::process::Command;
use tempfile::TempDir;
mod common;
use common::{bootstrap_minimal_cargo_repo, tool_on_path};
const FIXTURE_CRATE_NAME: &str = "anodizer-preflight-fixture";
const SECRET_SENTINEL: &str = "SUPERSECRET-PREFLIGHT-SENTINEL-VALUE";
fn write_fixture_config(dir: &std::path::Path) {
let yaml = format!(
r#"project_name: {FIXTURE_CRATE_NAME}
crates:
- name: {FIXTURE_CRATE_NAME}
path: .
publish:
aur:
private_key: "{{{{ .Env.PF_MISSING_AUR_KEY }}}}"
npms:
- scope: "@pf"
uploads:
- name: mirror
target: "https://uploads.example/{{{{ .ProjectName }}}}/{{{{ .ArtifactName }}}}"
signature: true
signs:
- artifacts: checksum
cmd: cosign
args: ["sign-blob", "--key", "env://PF_COSIGN_KEY", "{{{{ .Artifact }}}}"]
binary_signs:
- cmd: cosign
args: ["sign-blob", "--key", "env://PF_BINARY_COSIGN_KEY", "{{{{ .Artifact }}}}"]
sboms:
- cmd: pf-definitely-not-a-real-tool-9z
"#
);
std::fs::write(dir.join(".anodizer.yaml"), yaml).unwrap();
}
fn run_preflight(dir: &std::path::Path, extra_args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_anodizer"))
.current_dir(dir)
.arg("preflight")
.args(extra_args)
.env("PF_COSIGN_KEY", SECRET_SENTINEL)
.env("PF_BINARY_COSIGN_KEY", SECRET_SENTINEL)
.env_remove("PF_MISSING_AUR_KEY")
.env_remove("NPM_TOKEN")
.output()
.expect("spawn anodizer preflight")
}
#[test]
fn preflight_collects_all_failures_and_exits_nonzero() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &[]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"preflight must exit non-zero on failures; output:\n{combined}"
);
assert!(
combined.contains("PF_MISSING_AUR_KEY"),
"missing publisher SSH key env var not reported:\n{combined}"
);
assert!(
combined.contains("pf-definitely-not-a-real-tool-9z"),
"missing sbom tool not reported:\n{combined}"
);
assert!(
combined.contains("PF_COSIGN_KEY"),
"malformed cosign key env var not reported:\n{combined}"
);
assert!(
!combined.contains(SECRET_SENTINEL),
"preflight output echoed a secret value:\n{combined}"
);
}
#[test]
fn preflight_json_reports_same_failures() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &["--json"]);
assert!(!out.status.success(), "non-zero exit expected");
let stdout = String::from_utf8_lossy(&out.stdout);
let json_start = stdout.find('{').expect("JSON object in stdout");
let report: serde_json::Value =
serde_json::from_str(stdout[json_start..].trim()).expect("valid JSON report");
let failures = report["failures"].as_array().expect("failures array");
assert!(
failures.len() >= 3,
"expected at least 3 failures, got: {failures:?}"
);
let kinds: Vec<&str> = failures.iter().filter_map(|f| f["kind"].as_str()).collect();
assert!(kinds.contains(&"missing_env"), "kinds: {kinds:?}");
assert!(kinds.contains(&"missing_tool"), "kinds: {kinds:?}");
assert!(kinds.contains(&"bad_key_material"), "kinds: {kinds:?}");
assert!(
!stdout.contains(SECRET_SENTINEL),
"JSON output echoed a secret value"
);
}
#[test]
fn preflight_skip_drops_stage_requirements() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &["--skip=sign,sbom,publish"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!combined.contains("pf-definitely-not-a-real-tool-9z"),
"skipped sbom stage still contributed requirements:\n{combined}"
);
assert!(
!combined.contains("PF_COSIGN_KEY"),
"skipped sign stage still contributed signs requirements:\n{combined}"
);
assert!(
!combined.contains("PF_BINARY_COSIGN_KEY"),
"skipped sign stage still contributed binary_signs requirements:\n{combined}"
);
assert!(
!combined.contains("PF_MISSING_AUR_KEY"),
"skipped publish still contributed requirements:\n{combined}"
);
}
#[test]
fn preflight_publishers_allowlist_keeps_selected_drops_deselected_publisher() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &["--publish-only", "--publishers=npm"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("NPM_TOKEN"),
"allowlist-selected npm publisher's token requirement was dropped:\n{combined}"
);
assert!(
combined.contains("publish:npm"),
"npm token requirement not attributed to the selected npm publisher:\n{combined}"
);
assert!(
!combined.contains("PF_MISSING_AUR_KEY"),
"allowlist-deselected aur publisher still demanded its key:\n{combined}"
);
assert!(
!combined.contains("publish:aur"),
"deselected aur publisher still attributed a requirement source:\n{combined}"
);
assert!(
!combined.contains("PF_COSIGN_KEY"),
"--publishers npm must auto-deselect the signs surface (no --skip):\n{combined}"
);
assert!(
!combined.contains("PF_BINARY_COSIGN_KEY"),
"--publish-only must auto-skip the binary_signs surface (no --skip):\n{combined}"
);
assert!(
!combined.contains("stage:sign"),
"the sign slices must contribute nothing under --publish-only --publishers npm:\n{combined}"
);
assert!(
!combined.contains("stage:release") && !combined.contains("publish:github-release"),
"github-release must auto-deselect under --publishers npm (no --skip):\n{combined}"
);
}
#[test]
fn preflight_publish_only_empty_allowlist_keeps_signs_skips_binary_signs() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &["--publish-only"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("PF_COSIGN_KEY"),
"publish-only empty allowlist must keep the signs surface:\n{combined}"
);
assert!(
!combined.contains("PF_BINARY_COSIGN_KEY"),
"publish-only must skip the binary_signs surface regardless of allowlist:\n{combined}"
);
}
#[test]
fn preflight_full_scope_keeps_both_sign_surfaces() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &[]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("PF_COSIGN_KEY"),
"full-scope run must keep the signs surface:\n{combined}"
);
assert!(
combined.contains("PF_BINARY_COSIGN_KEY"),
"full-scope run must keep the binary_signs surface (main-job binary signing preserved):\n{combined}"
);
}
#[test]
fn preflight_publishers_uploads_keeps_signs_surface() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
write_fixture_config(tmp.path());
let out = run_preflight(tmp.path(), &["--publish-only", "--publishers=uploads"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("PF_COSIGN_KEY"),
"--publishers uploads must keep the signs surface (uploads consumes the sidecars):\n{combined}"
);
assert!(
combined.contains("stage:sign"),
"the signs slice must contribute its requirements under --publishers uploads:\n{combined}"
);
}