use std::process::Command;
use tempfile::TempDir;
mod common;
use common::{bootstrap_minimal_cargo_repo, run_git, 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}"
);
}
const RECONCILE_CRATE_NAME: &str = "anodizer-reconcile-fixture";
const RECONCILE_TAG: &str = "v0.1.0";
const RECONCILE_VERSION: &str = "0.1.0";
fn write_reconcile_fixture_config(dir: &std::path::Path, feed: &str) {
let yaml = format!(
r#"project_name: {RECONCILE_CRATE_NAME}
crates:
- name: {RECONCILE_CRATE_NAME}
path: .
tag_template: "v{{{{ .Version }}}}"
publish:
chocolatey:
required: true
api_key: fixture-key
source_repo: "{feed}"
"#
);
std::fs::write(dir.join(".anodizer.yaml"), yaml).unwrap();
}
fn rejected_feed_response() -> String {
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<entry>
<id>http://example.com/api/v2/Packages(Id='{RECONCILE_CRATE_NAME}',Version='{RECONCILE_VERSION}')</id>
<m:properties>
<d:PackageHash>deadbeef==</d:PackageHash>
<d:PackageHashAlgorithm>SHA512</d:PackageHashAlgorithm>
<d:PackageStatus>Rejected</d:PackageStatus>
<d:IsApproved>false</d:IsApproved>
</m:properties>
</entry>"#
);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn reconcile_fixture(
commits_after: usize,
) -> (TempDir, std::sync::Arc<std::sync::atomic::AtomicU32>) {
let tmp = TempDir::new().unwrap();
bootstrap_minimal_cargo_repo(tmp.path(), RECONCILE_CRATE_NAME);
let (addr, calls) =
anodizer_core::test_helpers::responder::spawn_oneshot_http_responder_with(|_| {
vec![rejected_feed_response(), rejected_feed_response()]
});
write_reconcile_fixture_config(tmp.path(), &format!("http://{addr}"));
run_git(tmp.path(), &["add", "-A"]);
run_git(
tmp.path(),
&["commit", "-q", "-m", "reconcile fixture config"],
);
run_git(tmp.path(), &["tag", RECONCILE_TAG]);
for i in 0..commits_after {
run_git(
tmp.path(),
&["commit", "-q", "--allow-empty", "-m", &format!("after-{i}")],
);
}
(tmp, calls)
}
fn run_reconcile_preflight(
dir: &std::path::Path,
env: &[(&str, &str)],
) -> (std::process::Output, Vec<serde_json::Value>) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_anodizer"));
cmd.current_dir(dir)
.args(["preflight", "--json", "--publish-only"])
.arg("--publishers=chocolatey");
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().expect("spawn anodizer preflight");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let json_start = stdout
.find('{')
.unwrap_or_else(|| panic!("no JSON object in stdout: {stdout}"));
let report: serde_json::Value =
serde_json::from_str(stdout[json_start..].trim()).expect("valid JSON report");
let rows = report["reconcile"]
.as_array()
.expect("reconcile array")
.clone();
(out, rows)
}
#[test]
fn reconcile_sweep_skipped_end_to_end_when_head_advanced_past_the_tag() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
if !tool_on_path("xmllint") {
eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
return;
}
let (tmp, calls) = reconcile_fixture(1);
let (out, rows) = run_reconcile_preflight(tmp.path(), &[]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success(),
"a skipped sweep must not gate the exit code; output:\n{combined}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"a skipped sweep must not probe the registry at all"
);
assert_eq!(rows.len(), 1, "expected one marker row, got: {rows:?}");
assert_eq!(rows[0]["publisher"], "*");
assert_eq!(rows[0]["state"], "skipped");
assert_eq!(rows[0]["blocking"], false);
assert!(
rows[0]["detail"]
.as_str()
.is_some_and(|d| d.contains(RECONCILE_TAG) && d.contains("advanced past it")),
"the marker must name the version and why it was skipped: {rows:?}"
);
}
#[test]
fn reconcile_sweep_probes_end_to_end_and_diverged_exits_nonzero_at_the_tag() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
if !tool_on_path("xmllint") {
eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
return;
}
let (tmp, calls) = reconcile_fixture(0);
let (out, rows) = run_reconcile_preflight(tmp.path(), &[]);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"a required publisher's divergence must exit non-zero; stderr:\n{stderr}"
);
assert!(
stderr.contains("required publisher(s) diverged"),
"the divergence bail must be what failed the run, not the environment gate: {stderr}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"the sweep must probe the feed exactly once"
);
assert_eq!(rows.len(), 1, "expected one publisher row, got: {rows:?}");
assert_eq!(rows[0]["publisher"], "chocolatey");
assert_eq!(rows[0]["state"], "diverged");
assert_eq!(rows[0]["blocking"], true);
}
#[test]
fn reconcile_sweep_probes_end_to_end_for_an_explicitly_declared_tag() {
if !tool_on_path("git") {
eprintln!("skipping: git not on PATH");
return;
}
if !tool_on_path("xmllint") {
eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
return;
}
let (tmp, calls) = reconcile_fixture(1);
let (out, rows) =
run_reconcile_preflight(tmp.path(), &[("ANODIZER_CURRENT_TAG", RECONCILE_TAG)]);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"a declared tag must be probed and its divergence must gate; stderr:\n{stderr}"
);
assert!(
stderr.contains("required publisher(s) diverged"),
"the divergence bail must be what failed the run: {stderr}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"a declared tag must be probed even from a tree that has moved past it"
);
assert_eq!(rows.len(), 1, "expected one publisher row, got: {rows:?}");
assert_eq!(rows[0]["publisher"], "chocolatey");
assert_eq!(rows[0]["state"], "diverged");
}