use ignition_core::actions::script::{read_script_input, script_run};
use ignition_core::client::ReqwestGatewayApi;
const ROUTE_PATH: &str = "/system/webdev/ign-cli/cli/scriptExec";
fn config_with(secret: Option<&str>) -> (tempfile::TempDir, ignition_core::config::Config) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
let secret_line = secret
.map(|secret| format!("webdev_secret = \"{secret}\"\n"))
.unwrap_or_default();
std::fs::write(
&path,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"
),
)
.expect("write config");
let config = ignition_core::config::load(&path).expect("config loads");
(dir, config)
}
#[tokio::test]
async fn missing_secret_refuses_with_zero_requests() {
let server = wiremock::MockServer::start().await;
let (_dir, config) = config_with(None);
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let err = script_run(&api, &config, "dev", "ign-cli", "2+2")
.await
.expect_err("not-configured refuses");
assert_eq!(err.code(), "script_exec_not_configured");
assert_eq!(err.exit_code(), 6);
assert!(
err.hint()
.unwrap()
.contains("ign webdev deploy --with-script-exec"),
"hint names the deploy flag verbatim"
);
assert!(
server
.received_requests()
.await
.expect("received requests")
.is_empty(),
"the refusal performs ZERO HTTP requests"
);
}
#[tokio::test]
async fn success_round_probes_then_execs_with_the_secret() {
let server = wiremock::MockServer::start().await;
let probe = 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_as_scoped(&server)
.await;
let exec = 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_as_scoped(&server)
.await;
let (_dir, config) = config_with(Some("cafebabe1234"));
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let result = script_run(&api, &config, "dev", "ign-cli", "print 'hello'\n2+2")
.await
.expect("success round");
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.result, serde_json::json!(4));
assert_eq!(result.elapsed_ms, 12);
let probe_requests = probe.received_requests().await;
assert_eq!(probe_requests.len(), 1);
let probe_body: serde_json::Value =
serde_json::from_slice(&probe_requests[0].body).expect("probe body parses");
assert_eq!(probe_body["action"], "version");
let exec_requests = exec.received_requests().await;
assert_eq!(exec_requests.len(), 1, "exec fired exactly once");
let exec_body: serde_json::Value =
serde_json::from_slice(&exec_requests[0].body).expect("exec body parses");
assert_eq!(exec_body["action"], "exec");
assert_eq!(exec_body["code"], "print 'hello'\n2+2");
let serialized = serde_json::to_value(&result).expect("serializes");
assert_eq!(serialized["stdout"], "hello\n");
assert_eq!(serialized["result"], 4);
assert_eq!(serialized["elapsedMs"], 12);
let mut keys: Vec<&str> = serialized
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
let debug = format!("{result:?}");
assert!(
!serialized.to_string().contains("cafebabe1234") && !debug.contains("cafebabe1234"),
"redaction: the secret never rides an output path"
);
}
#[tokio::test]
async fn probe_secret_mismatch_surfaces_honestly_without_exec() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(ROUTE_PATH))
.and(wiremock::matchers::body_json(
serde_json::json!({"action": "version"}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": false,
"error": {"code": "secret_mismatch", "message": "scriptExec secret mismatch"},
})),
)
.expect(1)
.mount(&server)
.await;
let (_dir, config) = config_with(Some("stale-secret"));
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let err = script_run(&api, &config, "dev", "ign-cli", "2+2")
.await
.expect_err("mismatch refuses");
assert_eq!(err.code(), "webdev_route_error");
assert_eq!(err.exit_code(), 6);
assert!(
err.to_string().contains("secret_mismatch"),
"the route's machine code rides verbatim: {err}"
);
assert!(
err.hint().unwrap().contains("--rotate-secret"),
"the existing family hint carries the redeploy/rotate advice"
);
assert_eq!(
server
.received_requests()
.await
.expect("received requests")
.len(),
1
);
}
#[tokio::test]
async fn exec_route_error_surfaces_the_traceback() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(ROUTE_PATH))
.and(wiremock::matchers::body_json(
serde_json::json!({"action": "version"}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"data": {"routeVersion": "1.0.0", "minCli": "1.0"},
})),
)
.mount(&server)
.await;
let exec = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(ROUTE_PATH))
.and(wiremock::matchers::body_json(
serde_json::json!({"action": "exec", "code": "1/0"}),
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"ok": false,
"error": {
"code": "route_error",
"message": "scriptExec route error",
"traceback": "Traceback (most recent call last):\n File \"<string>\", line 1, in ?\nZeroDivisionError: division by zero",
},
}),
))
.expect(1)
.mount_as_scoped(&server)
.await;
let (_dir, config) = config_with(Some("cafebabe1234"));
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let err = script_run(&api, &config, "dev", "ign-cli", "1/0")
.await
.expect_err("the route's denial surfaces");
assert_eq!(err.code(), "webdev_route_error");
assert_eq!(err.exit_code(), 6);
assert!(
err.to_string().contains("ZeroDivisionError"),
"the traceback rides the message: {err}"
);
assert!(
err.to_string().contains("route traceback:"),
"the 05-08 append marker: {err}"
);
let _ = exec; }
#[test]
fn read_input_rejects_both_forms() {
let err = read_script_input(Some("2+2"), Some("snippet.py")).expect_err("both refuse");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
}