use std::path::{Path, PathBuf};
use assert_cmd::Command;
use serde_json::Value;
const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
const DEAD_URL: &str = "http://127.0.0.1:1";
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) {
std::fs::write(
config,
format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"{url}\"\n"),
)
.expect("write config");
}
fn ign_version(config: &Path, envs: &[(&str, &str)]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command.env("IGNITION_CLI_CONFIG", config);
for (key, value) in envs {
command.env(key, value);
}
command
.args(["version", "--json"])
.output()
.expect("spawn ign")
}
async fn mount_gateway_info(server: &wiremock::MockServer, version: &str) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(GATEWAY_INFO_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"version": version, "edition": "Standard", "state": "RUNNING"}),
))
.expect(1)
.mount(server)
.await;
}
fn stderr_envelope(out: &std::process::Output) -> Value {
let stderr = String::from_utf8_lossy(&out.stderr);
let start = stderr.find('{').unwrap_or(0);
serde_json::from_str(&stderr[start..]).unwrap_or_else(|err| {
panic!("stderr envelope parses (from first '{{'; full stderr {stderr:?}): {err}")
})
}
#[tokio::test]
async fn reachable_modern_gateway_reports_version_exit_0() {
let server = wiremock::MockServer::start().await;
mount_gateway_info(&server, "8.3.2").await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_version(&config, &[]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["profile"], Value::String("dev".into()));
assert_eq!(
body["data"]["gateway"]["ignitionVersion"],
Value::String("8.3.2".into())
);
assert_eq!(
body["data"]["gateway"]["edition"],
Value::String("Standard".into())
);
assert_eq!(
body["data"]["gateway"]["state"],
Value::Null,
"state is not a model field (dropped in 02-01 — running state comes from /overview in 02-02)"
);
assert_eq!(body["data"]["warnings"], Value::Null, "no warnings");
}
#[tokio::test]
async fn answered_below_minimum_refuses_exit_6() {
let server = wiremock::MockServer::start().await;
mount_gateway_info(&server, "8.1.14").await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_version(&config, &[]);
assert_eq!(out.status.code(), Some(6), "target_state class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body = stderr_envelope(&out);
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(
body["error"]["code"],
Value::String("gateway_too_old".into())
);
let hint = body["error"]["hint"].as_str().expect("hint (CORE-05)");
assert!(hint.contains("8.3.1"), "hint names the minimum: {hint}");
let message = body["error"]["message"].as_str().expect("message");
assert!(
message.contains("8.1.14"),
"message reports what was found: {message}"
);
assert_eq!(
body["error"]["endpoint"],
Value::String(format!("{}{}", server.uri(), GATEWAY_INFO_PATH)),
"endpoint populated (CORE-05)"
);
}
#[tokio::test]
async fn auth_rejected_exit_5() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(GATEWAY_INFO_PATH))
.respond_with(wiremock::ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_version(&config, &[]);
assert_eq!(out.status.code(), Some(5), "auth class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body = stderr_envelope(&out);
assert_eq!(body["error"]["code"], Value::String("auth_rejected".into()));
assert_eq!(
body["error"]["endpoint"],
Value::String(format!("{}{}", server.uri(), GATEWAY_INFO_PATH))
);
assert!(body["error"]["hint"].is_string(), "hint present (CORE-05)");
}
#[test]
fn unreachable_gateway_degrades_to_warning_exit_0() {
let (_dir, config) = isolated_config();
write_profile_config(&config, DEAD_URL);
let out = ign_version(&config, &[]);
assert!(
out.status.success(),
"unreachable must exit 0 (LOCKED); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("envelope parses");
assert_eq!(body["data"]["gateway"], Value::Null, "no gateway report");
let warnings = body["data"]["warnings"]
.as_array()
.expect("warnings array present");
assert!(!warnings.is_empty(), "at least one warning");
assert!(
warnings[0]
.as_str()
.expect("warning is a string")
.contains("gateway unreachable"),
"warning names the problem: {warnings:?}"
);
let mut keys: Vec<&str> = body
.as_object()
.expect("envelope is an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["data", "ok", "profile"]);
}
#[test]
fn fresh_install_version_exit_0_profile_null() {
let (_dir, config) = isolated_config();
assert!(!config.exists(), "fixture sanity: no config file");
let out = ign_version(&config, &[]);
assert!(
out.status.success(),
"version must work on a fresh install; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["profile"], Value::Null);
assert_eq!(body["data"]["gateway"], Value::Null);
assert_eq!(body["data"]["warnings"], Value::Null);
}
#[tokio::test]
async fn ignition_url_overlay_beats_profile_url_before_client_construction() {
let server = wiremock::MockServer::start().await;
mount_gateway_info(&server, "8.3.2").await;
let (_dir, config) = isolated_config();
write_profile_config(&config, DEAD_URL);
let out = ign_version(&config, &[("IGNITION_URL", server.uri().as_str())]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value = serde_json::from_slice(&out.stdout).expect("envelope parses");
assert_eq!(
body["data"]["gateway"]["ignitionVersion"],
Value::String("8.3.2".into()),
"the overlay URL (live mock) must have been used, not the dead profile URL"
);
assert_eq!(body["data"]["warnings"], Value::Null, "no degradation");
}