use std::path::{Path, PathBuf};
use std::process::Output;
use assert_cmd::Command;
use serde_json::Value;
struct LiveEnv {
url: String,
token: String,
}
fn live_url() -> Option<String> {
std::env::var("IGNITION_LIVE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn live_token() -> Option<String> {
std::env::var("IGNITION_LIVE_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn live_env_reads() -> Option<LiveEnv> {
match (live_url(), live_token()) {
(Some(url), Some(token)) => Some(LiveEnv { url, token }),
_ => None,
}
}
fn live_env_mutations() -> Option<LiveEnv> {
if std::env::var("IGNITION_LIVE_MUTATIONS").as_deref() != Ok("1") {
return None;
}
live_env_reads()
}
fn skip(message: &str) {
eprintln!("skipping: {message}");
}
fn isolated_live_config(env: &LiveEnv) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(
&config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\n",
env.url
),
)
.expect("write config");
(dir, config)
}
fn ign(config: &Path, env: &LiveEnv, args: &[&str]) -> Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", &env.token);
for knob in [
"IGNITION_URL",
"IGNITION_PROFILE",
"IGNITION_JSON",
"IGNITION_YES",
"IGNITION_LIVE_URL",
"IGNITION_LIVE_TOKEN",
"IGNITION_LIVE_MUTATIONS",
] {
command.env_remove(knob);
}
let mut all = args.to_vec();
all.push("--json");
command.args(&all).output().expect("spawn ign")
}
fn data_envelope(out: &Output) -> Value {
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(stdout.trim())
.unwrap_or_else(|err| panic!("stdout envelope parses ({err}): {stdout}"))
}
fn err_envelope(out: &Output) -> Value {
let stderr = String::from_utf8_lossy(&out.stderr);
let from = stderr.find('{').expect("envelope starts somewhere");
serde_json::from_str(&stderr[from..]).expect("envelope parses")
}
fn expect_ok(what: &str, out: &Output) {
assert!(
out.status.success(),
"{what} failed (exit {:?}):\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn license_status_live() {
let Some(env) = live_env_reads() else {
skip("IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN not set — live gate idle");
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let out = ign(&config, &env, &["license", "status"]);
expect_ok("license status", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
assert!(
body["data"]["license"].is_object(),
"data.license parses: {body}"
);
assert!(
body["data"]["license"]["busy"].is_boolean(),
"busy rides as a bool: {body}"
);
for array in ["cloud", "certificate", "hardware", "embedded", "leased"] {
assert!(
body["data"]["license"][array].is_array(),
"{array} rides as an array: {body}"
);
}
assert!(
body["data"]["trial"]["trialSecondsLeft"].is_number(),
"trialSecondsLeft present: {body}"
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn redundancy_status_live() {
let Some(env) = live_env_reads() else {
skip("IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN not set — live gate idle");
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let out = ign(&config, &env, &["redundancy", "status"]);
expect_ok("redundancy status", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
let status = &body["data"]["status"];
assert!(status.is_object(), "data.status parses: {body}");
let role = status["role"].as_str().expect("role is a string");
assert!(
["Independent", "Master", "Backup"].contains(&role),
"role within the captured vocabulary: {role}"
);
let uptime = status["uptime"].as_i64().expect("uptime is an integer");
assert!(uptime >= 0, "uptime non-negative: {uptime}");
assert!(
status["lastSyncTimestamp"].is_i64(),
"lastSyncTimestamp rides as an integer: {body}"
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn gan_status_live() {
let Some(env) = live_env_reads() else {
skip("IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN not set — live gate idle");
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let out = ign(&config, &env, &["gan", "status"]);
expect_ok("gan status", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
let status = &body["data"]["status"];
assert!(status.is_object(), "data.status parses: {body}");
let total = status["totalConnections"]
.as_i64()
.expect("totalConnections is an integer");
let running = status["runningConnections"]
.as_i64()
.expect("runningConnections is an integer");
assert!(total >= 0, "total non-negative: {total}");
assert!(running >= 0, "running non-negative: {running}");
assert!(
running <= total,
"live-truth sanity: running ({running}) ≤ total ({total})"
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn api_call_envelope_live() {
let Some(env) = live_env_reads() else {
skip("IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN not set — live gate idle");
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let out = ign(
&config,
&env,
&[
"api",
"call",
"--method",
"GET",
"--path",
"/data/api/v1/gateway-info",
],
);
expect_ok("api call gateway-info", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
assert_eq!(
body["data"]["method"],
Value::String("GET".into()),
"{body}"
);
assert_eq!(
body["data"]["path"],
Value::String("/data/api/v1/gateway-info".into()),
"{body}"
);
assert_eq!(body["data"]["result"]["status"], Value::Number(200.into()));
let result = &body["data"]["result"]["data"];
assert!(
result.is_object(),
"the gateway's own JSON parses as an object: {body}"
);
assert!(
result["ignitionVersion"].is_string(),
"gateway-info.ignitionVersion rides verbatim: {body}"
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
fn api_call_4xx_partition_live() {
let Some(env) = live_env_reads() else {
skip("IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN not set — live gate idle");
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let out = ign(
&config,
&env,
&[
"api",
"call",
"--method",
"GET",
"--path",
"/data/api/v1/definitely-not-a-real-endpoint",
],
);
assert_eq!(
out.status.code(),
Some(6),
"unknown path exits 6 (not_found); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope = err_envelope(&out);
assert_eq!(envelope["ok"], Value::Bool(false));
assert_eq!(
envelope["error"]["code"],
Value::String("not_found".into()),
"the capture-backed slug: {envelope}"
);
let out = ign(
&config,
&env,
&[
"api",
"call",
"--method",
"DELETE",
"--path",
"/data/api/v1/gateway-info",
],
);
assert_eq!(
out.status.code(),
Some(6),
"the CAPTURED answer (404-empty) exits 6 — the plan's 405/exit-2 \
hypothesis was falsified by the 09-02 capture; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope = err_envelope(&out);
assert_eq!(envelope["ok"], Value::Bool(false));
assert_eq!(
envelope["error"]["code"],
Value::String("not_found".into()),
"404 classifies before the api-call catch-all: {envelope}"
);
}
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
fn bundle_round_trip_live() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().expect("download tempdir");
let out = ign(&config, &env, &["diagnostics", "bundle", "generate"]);
expect_ok("bundle generate", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
let born = body["data"]["state"].as_str().expect("state is a string");
assert!(
["Generating", "Valid"].contains(&born),
"the birth state is within the CAPTURED vocabulary \
(Generating observed at birth on both rigs): {born}"
);
let out = ign(
&config,
&env,
&[
"diagnostics",
"bundle",
"wait",
"--interval",
"2",
"--timeout",
"300",
],
);
expect_ok("bundle wait", &out);
let body = data_envelope(&out);
assert_eq!(
body["data"]["state"],
Value::String("Valid".into()),
"the captured terminal state: {body}"
);
let zip_path = tmp.path().join("b.zip");
let out = ign(
&config,
&env,
&[
"diagnostics",
"bundle",
"download",
"--output",
zip_path.to_str().expect("utf-8 temp path"),
],
);
expect_ok("bundle download", &out);
let bytes = std::fs::read(&zip_path).expect("the bundle file landed");
assert!(!bytes.is_empty(), "the bundle is non-empty");
assert_eq!(
&bytes[..4],
b"PK\x03\x04",
"the ZIP magic opens the body (capture §5)"
);
let out = ign(&config, &env, &["diagnostics", "bundle", "status"]);
expect_ok("bundle status after download", &out);
let body = data_envelope(&out);
assert_eq!(body["ok"], Value::Bool(true), "{body}");
assert_eq!(
body["data"]["state"],
Value::String("Valid".into()),
"the ready state persists after download: {body}"
);
let file_size = body["data"]["fileSize"]
.as_u64()
.expect("fileSize present at Valid (bytes)");
assert_eq!(
file_size,
bytes.len() as u64,
"fileSize equals the downloaded bytes exactly (capture decision 2)"
);
}
static LIVE_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());