use std::path::PathBuf;
use std::process::Command;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn cleanlib_bin() -> PathBuf {
PathBuf::from(std::env!("CARGO_BIN_EXE_cleanlib"))
}
fn unique_tmp_dir(tag: &str) -> PathBuf {
let ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("cleanlib-fix-test-{}-{}", tag, ns));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_file(path: &std::path::Path, content: &str) {
std::fs::write(path, content).unwrap();
}
async fn mock_verdicts(recipes: &[(&str, &str, &str, &str, &str)]) -> MockServer {
let server = MockServer::start().await;
for (eco, name, ver, decision, rec) in recipes {
let resp_body = serde_json::json!({
"verdict_id": format!("01T-{}-{}", name, ver),
"verdict": if *decision == "ALLOW" { "ALLOWED_NO_FINDINGS" } else { "VECTOR_VERDICT" },
"source": "test",
"confidence": 0.99,
"composite_score": if *decision == "ALLOW" { 5 } else { 95 },
"reasoning": format!("fixture: {} {}", name, decision),
"decision": decision,
"suggested_actions": if rec.is_empty() {
Vec::<String>::new()
} else {
vec![format!("recommended_version={}", rec)]
},
"computed_at": "2026-06-30T00:00:00Z"
});
let pattern = format!(
r"^/v1/customer/verdicts/{}/{}/{}$",
regex_escape(eco),
regex_escape(name),
regex_escape(ver)
);
Mock::given(method("GET"))
.and(path_regex(&pattern))
.respond_with(ResponseTemplate::new(200).set_body_json(resp_body))
.mount(&server)
.await;
}
server
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' | '^' | '$'
| '/' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn run_fix(endpoint: &str, lockfile: &std::path::Path, extra: &[&str]) -> std::process::Output {
Command::new(cleanlib_bin())
.env("CLEANLIBRARY_ENDPOINT", endpoint)
.env("CLEANLIBRARY_API_KEY", "test-bearer")
.args(["fix", "--lockfile", lockfile.to_str().unwrap()])
.args(extra)
.output()
.expect("invoke cleanlib fix")
}
#[tokio::test]
async fn cleanlib_211_dry_run_reports_upgrade_and_does_not_write() {
let dir = unique_tmp_dir("211-dry");
let lockfile = dir.join("package-lock.json");
write_file(
&lockfile,
r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" },
"node_modules/lodash": { "version": "4.17.21" }
}
}"#,
);
let original_content = std::fs::read_to_string(&lockfile).unwrap();
let server = mock_verdicts(&[
("npm", "cors", "2.8.4", "DENY", "2.8.5"),
("npm", "lodash", "4.17.21", "ALLOW", ""),
])
.await;
let out = run_fix(&server.uri(), &lockfile, &[]);
assert_eq!(
out.status.code(),
Some(4),
"dry-run with pending upgrade must exit 4; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("DRY RUN"), "{}", stdout);
assert!(stdout.contains("cors"), "{}", stdout);
assert!(stdout.contains("2.8.4 → 2.8.5"), "{}", stdout);
assert!(stdout.contains("blast radius") || stdout.contains("blast"), "{}", stdout);
assert!(stdout.contains("verdict ALLOW"), "{}", stdout);
let after = std::fs::read_to_string(&lockfile).unwrap();
assert_eq!(after, original_content, "dry-run must not mutate lockfile");
}
#[tokio::test]
async fn cleanlib_214_apply_flag_mutates_lockfile() {
let dir = unique_tmp_dir("214-apply");
let lockfile = dir.join("package-lock.json");
write_file(
&lockfile,
r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" }
}
}"#,
);
let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;
let out = run_fix(&server.uri(), &lockfile, &["--apply"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = std::fs::read_to_string(&lockfile).unwrap();
assert!(after.contains("\"2.8.5\""));
assert!(!after.contains("\"2.8.4\""));
let backup_present = std::fs::read_dir(&dir).unwrap().any(|e| {
e.unwrap()
.file_name()
.to_string_lossy()
.contains("cleanlib-backup-")
});
assert!(backup_present, "backup file must exist next to lockfile");
}
#[tokio::test]
async fn cleanlib_213_risk_accept_yaml_suppresses_matching_upgrade() {
let dir = unique_tmp_dir("213-risk");
let lockfile = dir.join("package-lock.json");
write_file(
&lockfile,
r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" }
}
}"#,
);
let risk_yaml = dir.join("risk.yaml");
write_file(
&risk_yaml,
"risk_accepted:\n - package: cors\n version_range: \"<2.8.5\"\n justification: \"waiver\"\n",
);
let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;
let out = run_fix(
&server.uri(),
&lockfile,
&["--risk-accept", risk_yaml.to_str().unwrap()],
);
assert!(
out.status.success(),
"got {:?}, stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("risk-accepted"), "{}", stdout);
assert!(
stdout.contains("no upgrades") || stdout.contains("planned: 0"),
"{}",
stdout
);
}
#[tokio::test]
async fn cleanlib_214_allow_list_filters_upgrades() {
let dir = unique_tmp_dir("214-allow");
let lockfile = dir.join("package-lock.json");
write_file(
&lockfile,
r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" },
"node_modules/lodash": { "version": "4.17.15" }
}
}"#,
);
let allow_list = dir.join("allow.txt");
write_file(&allow_list, "# only cors permitted\ncors\n");
let server = mock_verdicts(&[
("npm", "cors", "2.8.4", "DENY", "2.8.5"),
("npm", "lodash", "4.17.15", "DENY", "4.17.21"),
])
.await;
let out = run_fix(
&server.uri(),
&lockfile,
&["--allow-list", allow_list.to_str().unwrap()],
);
assert_eq!(out.status.code(), Some(4));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("cors"), "{}", stdout);
assert!(
stdout.contains("not in --allow-list") || stdout.contains("allow-list"),
"{}",
stdout
);
assert!(stdout.contains("planned: 1"), "{}", stdout);
}
#[tokio::test]
async fn cleanlib_212_auto_pr_emits_bundle_without_touching_tree() {
let dir = unique_tmp_dir("212-pr");
let lockfile = dir.join("package-lock.json");
write_file(
&lockfile,
r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" }
}
}"#,
);
let original = std::fs::read_to_string(&lockfile).unwrap();
let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;
let out = run_fix(
&server.uri(),
&lockfile,
&[
"--auto-pr",
"--pr-remote",
"git@github.com:acme/app.git",
"--apply",
],
);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("auto-pr bundle") || stdout.contains("Title: cleanlib fix"),
"{}",
stdout
);
assert!(stdout.contains("git@github.com:acme/app.git"), "{}", stdout);
assert!(stdout.contains("2.8.4 → 2.8.5"), "{}", stdout);
let after = std::fs::read_to_string(&lockfile).unwrap();
assert_eq!(after, original);
}
#[tokio::test]
async fn cleanlib_214_unknown_lockfile_kind_is_rejected() {
let dir = unique_tmp_dir("214-unknown");
let lockfile = dir.join("random.txt");
write_file(&lockfile, "some content");
let out = Command::new(cleanlib_bin())
.env("CLEANLIBRARY_ENDPOINT", "https://cleanlibrary.clnstrt.dev")
.env("CLEANLIBRARY_API_KEY", "test-bearer")
.args(["fix", "--lockfile", lockfile.to_str().unwrap()])
.output()
.expect("invoke cleanlib fix");
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("unrecognized lockfile") || stderr.contains("supported"),
"{}",
stderr
);
}