use std::path::PathBuf;
use std::process::Output;
use assert_cmd::Command;
use serde_json::Value;
fn live_env() -> Option<(String, String)> {
let url = std::env::var("IGNITION_LIVE_URL")
.ok()
.filter(|value| !value.trim().is_empty())?;
let token = std::env::var("IGNITION_LIVE_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty())?;
Some((url, token))
}
fn mutations_allowed() -> bool {
std::env::var("IGNITION_LIVE_MUTATIONS").is_ok_and(|value| value == "1")
}
fn controller_mode() -> bool {
std::env::var("IGNITION_LIVE_EAM_CONTROLLER").is_ok_and(|value| value == "1")
}
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn ign(config: &std::path::Path, url: &str, token: &str, args: &[&str]) -> Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", token)
.env("IGNITION_URL", url);
command.args(args).output().expect("spawn ign")
}
fn stderr_envelope(out: &Output) -> Value {
let stderr = String::from_utf8_lossy(&out.stderr);
let start = stderr.find('{').unwrap_or(0);
serde_json::from_str(&stderr[start..]).expect("error envelope parses")
}
#[test]
#[ignore = "live gate: needs IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn non_controller_gateway_refuses_eam_not_controller() {
let Some((url, token)) = live_env() else {
return; };
if controller_mode() {
return; }
let (_dir, config) = isolated_config();
let out = ign(&config, &url, &token, &["eam", "history", "--compact"]);
assert_eq!(
out.status.code(),
Some(6),
"the state gate is target-state, not auth"
);
let body = stderr_envelope(&out);
assert_eq!(
body["error"]["code"],
Value::String("eam_not_controller".into()),
"never a misleading auth_rejected"
);
let hint = body["error"]["hint"].as_str().expect("hint");
assert!(
hint.contains("installMode") && hint.contains("Controller"),
"the hint names the manual flip: {hint}"
);
}
#[test]
#[ignore = "live gate: needs IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + controller + mutations opt-in"]
fn controller_gateway_creates_forces_and_reads_history() {
let Some((url, token)) = live_env() else {
return; };
if !controller_mode() || !mutations_allowed() {
return; }
let (_dir, config) = isolated_config();
let name = format!("e2e-backup-{}", std::process::id());
let out = ign(
&config,
&url,
&token,
&[
"eam",
"task",
"new",
&name,
"eam_backup",
"--setting",
"controllerIsTargetKey=true",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("create envelope parses");
assert_eq!(
body["data"]["schedule_mode"],
Value::String("OnDemand".into())
);
let out = ign(
&config,
&url,
&token,
&["eam", "task", "force", &name, "--yes", "--compact"],
);
assert_eq!(
out.status.code(),
Some(0),
"dispatch acceptance — outcomes land in history: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("force envelope parses");
assert_eq!(body["data"]["dispatched"], Value::Bool(true));
if let Some(entry) = body["data"]["history"].as_object() {
eprintln!("live history entry: {entry:?}");
}
let out = ign(&config, &url, &token, &["eam", "history", "--compact"]);
assert_eq!(out.status.code(), Some(0));
let body: Value = serde_json::from_slice(&out.stdout).expect("history envelope parses");
let items = body["data"]["items"].as_array().expect("items array");
assert!(
items.iter().any(|item| item["taskName"]
.as_str()
.is_some_and(|n| n.starts_with(&name))),
"the forced run's entry is visible in history"
);
let out = ign(&config, &url, &token, &["eam", "tasks", "--compact"]);
assert_eq!(out.status.code(), Some(0));
let body: Value = serde_json::from_slice(&out.stdout).expect("tasks envelope parses");
assert!(
body["data"]["tasks"]
.as_array()
.expect("tasks array")
.iter()
.any(|task| task["name"].as_str() == Some(name.as_str())),
"the created definition lists"
);
}