use std::path::{Path, PathBuf};
use assert_cmd::Command;
const ROUTE_PATH: &str = "/system/webdev/ign-cli/cli/scriptExec";
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn write_profile_config(config: &Path, url: &str, secret: Option<&str>) {
let secret_line = secret
.map(|secret| format!("webdev_secret = \"{secret}\"\n"))
.unwrap_or_default();
std::fs::write(
config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{url}\"\n\
auth = {{ token_env = \"IGNITION_TOKEN\" }}\n{secret_line}"
),
)
.expect("write config");
}
fn ign(config: &Path, url: &str, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url);
command.args(args).output().expect("spawn ign")
}
fn stdout_for_golden(out: &std::process::Output) -> &str {
let stdout = std::str::from_utf8(&out.stdout).expect("utf-8 stdout");
stdout.strip_suffix('\n').unwrap_or(stdout)
}
fn stderr_envelope(out: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
let start = stderr.find('{').unwrap_or(0);
stderr[start..].to_string()
}
async fn mount_script_round(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(ROUTE_PATH))
.and(wiremock::matchers::body_json(
serde_json::json!({"action": "version"}),
))
.and(wiremock::matchers::header(
"x-ignition-cli-secret",
"cafebabe1234",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"data": {"routeVersion": "1.0.0", "minCli": "1.0"},
})),
)
.expect(1)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(ROUTE_PATH))
.and(wiremock::matchers::body_json(
serde_json::json!({"action": "exec", "code": "print 'hello'\n2+2"}),
))
.and(wiremock::matchers::header(
"x-ignition-cli-secret",
"cafebabe1234",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"data": {"stdout": "hello\n", "result": 4, "elapsedMs": 12},
})),
)
.expect(1)
.mount(server)
.await;
}
#[tokio::test]
async fn script_run_success_json_golden() {
let server = wiremock::MockServer::start().await;
mount_script_round(&server).await;
let (_config_dir, config) = isolated_config();
write_profile_config(&config, &server.uri(), Some("cafebabe1234"));
let out = ign(
&config,
&server.uri(),
&["script", "run", "--code", "print 'hello'\n2+2", "--compact"],
);
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let rendered = stdout_for_golden(&out);
assert!(
!rendered.contains("cafebabe1234"),
"redaction: stdout never carries the secret"
);
assert!(
!String::from_utf8_lossy(&out.stderr).contains("cafebabe1234"),
"redaction: stderr never carries the secret"
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
rendered,
snapbox::str![[
r#"{"ok":true,"profile":"dev","data":{"stdout":"hello/n","result":4,"elapsedMs":[..]}}"#
]],
);
}
#[tokio::test]
async fn script_run_success_human_golden() {
let server = wiremock::MockServer::start().await;
mount_script_round(&server).await;
let (_config_dir, config) = isolated_config();
write_profile_config(&config, &server.uri(), Some("cafebabe1234"));
let out = ign(
&config,
&server.uri(),
&["script", "run", "--code", "print 'hello'\n2+2"],
);
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
stdout:
hello
result: 4
elapsed: 12 ms"#]],
);
}
#[tokio::test]
async fn script_run_missing_secret_refuses_exit_6_zero_http() {
let server = wiremock::MockServer::start().await;
let (_config_dir, config) = isolated_config();
write_profile_config(&config, &server.uri(), None);
let out = ign(
&config,
&server.uri(),
&["script", "run", "--code", "2+2", "--compact"],
);
assert_eq!(out.status.code(), Some(6), "target state");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: serde_json::Value =
serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["ok"], serde_json::Value::Bool(false));
assert_eq!(body["profile"], serde_json::Value::String("dev".into()));
assert_eq!(
body["error"]["code"],
serde_json::Value::String("script_exec_not_configured".into())
);
let hint = body["error"]["hint"].as_str().expect("hint rides");
assert!(
hint.contains("ign webdev deploy --with-script-exec"),
"the hint names the deploy flag verbatim: {hint}"
);
assert!(
server
.received_requests()
.await
.expect("received requests")
.is_empty(),
"the refusal performs ZERO HTTP requests"
);
}
#[test]
fn script_run_missing_secret_human_shape() {
let (_config_dir, config) = isolated_config();
write_profile_config(&config, "http://127.0.0.1:1", None);
let out = ign(
&config,
"http://127.0.0.1:1",
&["script", "run", "--code", "2+2"],
);
assert_eq!(out.status.code(), Some(6));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("scriptExec is not configured"),
"the refusal names the state: {stderr}"
);
assert!(
stderr.contains("ign webdev deploy --with-script-exec"),
"the hint names the deploy flag: {stderr}"
);
}
#[test]
fn script_run_both_inputs_refuse_invalid_input() {
let (_config_dir, config) = isolated_config();
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"script",
"run",
"--code",
"2+2",
"--file",
"snippet.py",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: serde_json::Value =
serde_json::from_str(&stderr_envelope(&out)).expect("error envelope parses");
assert_eq!(body["profile"], serde_json::Value::Null, "usage leads");
assert_eq!(
body["error"]["code"],
serde_json::Value::String("invalid_input".into())
);
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("--code") && message.contains("--file"),
"the reason names both flags: {message}"
);
}