use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use assert_cmd::Command;
use serde_json::Value;
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_cmd(config: &Path, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command.env("IGNITION_CLI_CONFIG", config);
command.env("IGNITION_TOKEN", "ign-contract-test:0123456789abcdef");
for knob in [
"IGNITION_URL",
"IGNITION_PROFILE",
"IGNITION_JSON",
"IGNITION_YES",
] {
command.env_remove(knob);
}
let mut all = args.to_vec();
all.push("--json");
command.args(&all).output().expect("spawn ign")
}
fn ign_status(config: &Path, family: &str) -> std::process::Output {
ign_cmd(config, &[family, "status"])
}
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 (full stderr {stderr:?}): {err}"))
}
async fn mount_get(server: &wiremock::MockServer, file: &str, body: String) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/data/api/v1/{file}")))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(body))
.expect(1)
.mount(server)
.await;
}
fn licenses_body_with_unknown_key() -> String {
r#"{
"cloud": [], "certificate": [], "hardware": [], "embedded": [], "leased": [],
"effective": { "lastUpdated": 1788748551614 },
"leasedUnactivateTimeoutMS": 7500,
"busy": false,
"zzBrandNewPointReleaseKey": {"future": true}
}"#
.to_string()
}
fn trial_body() -> String {
r#"{
"licenseMode": "Trial", "trialState": "AllInDemo", "trialSecondsLeft": 6823,
"expired": false, "emergency": false, "emergencySecondsLeft": 0,
"development": false, "developmentSecondsLeft": 0
}"#
.to_string()
}
#[tokio::test]
async fn license_status_envelope() {
let server = wiremock::MockServer::start().await;
mount_get(&server, "licenses", licenses_body_with_unknown_key()).await;
mount_get(&server, "trial", trial_body()).await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_status(&config, "license");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let body: Value = serde_json::from_str(&stdout).expect("success envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["profile"], Value::String("dev".into()));
assert_eq!(body["data"]["license"]["busy"], Value::Bool(false));
assert_eq!(
body["data"]["license"]["effective"]["lastUpdated"],
Value::Number(1_788_748_551_614i64.into()),
"epoch-ms stamp rides the gateway-native key"
);
assert_eq!(
body["data"]["license"]["leasedUnactivateTimeoutMS"],
Value::Number(7500.into())
);
assert!(
body["data"]["license"]["hardware"].is_array(),
"the typed hardware skeleton serializes"
);
assert_eq!(
body["data"]["trial"]["licenseMode"],
Value::String("Trial".into())
);
assert_eq!(
body["data"]["trial"]["trialSecondsLeft"],
Value::Number(6823.into())
);
assert_eq!(
body["data"]["license"]["zzBrandNewPointReleaseKey"],
serde_json::json!({"future": true}),
"the unknown key rides the flatten passthrough into the envelope"
);
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"]);
}
#[tokio::test]
async fn redundancy_status_envelope() {
let server = wiremock::MockServer::start().await;
mount_get(
&server,
"redundancy",
r#"{"role":"Independent","projectState":"Unknown","activityLevel":"Active","localId":"203.0.113.22","peerConnected":false,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":460959,"lastSyncTimestamp":-1,"zzFutureKey":42}"#
.to_string(),
)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_status(&config, "redundancy");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["data"]["status"]["role"], "Independent");
assert_eq!(
body["data"]["status"]["uptime"],
Value::Number(460_959.into()),
"ms since gateway start (wall-clock-proven capture)"
);
assert_eq!(
body["data"]["status"]["lastSyncTimestamp"],
Value::Number((-1).into()),
"the -1 never-synced sentinel rides verbatim"
);
assert_eq!(
body["data"]["status"]["zzFutureKey"],
Value::Number(42.into()),
"unknown keys ride the passthrough"
);
}
fn write_pair_config(config: &Path, url_a: &str, url_b: &str) {
std::fs::write(
config,
format!(
"active = \"a\"\n\n[profiles.a]\nurl = \"{url_a}\"\n\n[profiles.b]\nurl = \"{url_b}\"\n"
),
)
.expect("write config");
}
const PAIR_A_ACTIVE: &str = r#"{"role":"Master","projectState":"Good","activityLevel":"Active","localId":"203.0.113.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":29279,"lastSyncTimestamp":1791586091827}"#;
const PAIR_B_COLD: &str = r#"{"role":"Backup","projectState":"Good","activityLevel":"Cold","localId":"203.0.113.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586091875}"#;
const PAIR_A_COLD_AFTER: &str = r#"{"role":"Master","projectState":"Good","activityLevel":"Cold","localId":"203.0.113.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586358959}"#;
const PAIR_B_ACTIVE_AFTER: &str = r#"{"role":"Backup","projectState":"Good","activityLevel":"Active","localId":"203.0.113.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":30539,"lastSyncTimestamp":1791586359045}"#;
async fn run_pair(body_a: &str, body_b: &str, extra: &[&str]) -> std::process::Output {
let server_a = wiremock::MockServer::start().await;
let server_b = wiremock::MockServer::start().await;
mount_get(&server_a, "redundancy", body_a.to_string()).await;
mount_get(&server_b, "redundancy", body_b.to_string()).await;
let (_dir, config) = isolated_config();
write_pair_config(&config, &server_a.uri(), &server_b.uri());
let mut args = vec!["redundancy", "status", "--peer", "b"];
args.extend_from_slice(extra);
ign_cmd(&config, &args)
}
async fn run_pair_human(body_a: &str, body_b: &str) -> std::process::Output {
let server_a = wiremock::MockServer::start().await;
let server_b = wiremock::MockServer::start().await;
mount_get(&server_a, "redundancy", body_a.to_string()).await;
mount_get(&server_b, "redundancy", body_b.to_string()).await;
let (_dir, config) = isolated_config();
write_pair_config(&config, &server_a.uri(), &server_b.uri());
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command.env("IGNITION_CLI_CONFIG", &config);
command.env("IGNITION_TOKEN", "ign-contract-test:0123456789abcdef");
for knob in [
"IGNITION_URL",
"IGNITION_PROFILE",
"IGNITION_JSON",
"IGNITION_YES",
] {
command.env_remove(knob);
}
command
.args(["redundancy", "status", "--peer", "b"])
.output()
.expect("spawn ign")
}
fn envelope(out: &std::process::Output) -> Value {
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses")
}
#[tokio::test]
async fn redundancy_status_peer_names_the_active_node() {
let out = run_pair(PAIR_A_ACTIVE, PAIR_B_COLD, &[]).await;
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = envelope(&out);
let active = &body["data"]["active_node"];
assert_eq!(active["active"], "this");
assert_eq!(active["this_role"], "Master");
assert_eq!(active["peer_role"], "Backup");
assert_eq!(active["peer_profile"], "b");
assert_eq!(active["peer"]["activityLevel"], "Cold");
assert_eq!(active["peer"]["uptime"], Value::Number((-1).into()));
assert_eq!(body["profile"], "a", "the envelope profile stays active");
assert!(
!String::from_utf8_lossy(&out.stderr).contains("warn"),
"a cold standby is not a fault"
);
}
#[tokio::test]
async fn redundancy_status_peer_after_failover() {
let out = run_pair(PAIR_A_COLD_AFTER, PAIR_B_ACTIVE_AFTER, &[]).await;
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = envelope(&out);
assert_eq!(body["data"]["active_node"]["active"], "peer");
assert_eq!(body["data"]["active_node"]["this_role"], "Master");
assert_eq!(body["data"]["active_node"]["peer_role"], "Backup");
}
#[tokio::test]
async fn redundancy_status_without_peer_is_unchanged() {
let server = wiremock::MockServer::start().await;
mount_get(&server, "redundancy", PAIR_A_ACTIVE.to_string()).await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_status(&config, "redundancy");
assert!(out.status.success());
let body = envelope(&out);
let mut keys: Vec<&str> = body["data"]
.as_object()
.expect("data is an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["status"]);
}
#[tokio::test]
async fn redundancy_status_peer_split_brain() {
let out = run_pair(PAIR_A_ACTIVE, PAIR_B_ACTIVE_AFTER, &[]).await;
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(envelope(&out)["data"]["active_node"]["active"], "both");
let human = run_pair_human(PAIR_A_ACTIVE, PAIR_B_ACTIVE_AFTER).await;
assert!(human.status.success());
let text = String::from_utf8_lossy(&human.stdout);
assert!(
text.contains("active node: both report Active (split-brain)"),
"stdout: {text}"
);
}
#[tokio::test]
async fn redundancy_status_peer_neither() {
let out = run_pair(PAIR_A_COLD_AFTER, PAIR_B_COLD, &[]).await;
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(envelope(&out)["data"]["active_node"]["active"], "neither");
}
#[tokio::test]
async fn redundancy_status_peer_same_profile_refused() {
let server = wiremock::MockServer::start().await;
let (_dir, config) = isolated_config();
write_pair_config(&config, &server.uri(), &server.uri());
let out = ign_cmd(&config, &["redundancy", "status", "--peer", "a"]);
assert_eq!(out.status.code(), Some(2), "invalid_input is exit 2");
let body = stderr_envelope(&out);
assert_eq!(body["error"]["code"], "invalid_input");
assert_eq!(
server.received_requests().await.expect("recording").len(),
0,
"no request may leave the process"
);
}
#[tokio::test]
async fn redundancy_status_peer_human() {
let out = run_pair_human(PAIR_A_ACTIVE, PAIR_B_COLD).await;
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("active node: this (Master)"),
"stdout: {text}"
);
assert!(
text.contains("peer (b): role Backup, activity Cold"),
"stdout: {text}"
);
}
#[tokio::test]
async fn gan_status_envelope() {
let server = wiremock::MockServer::start().await;
mount_get(
&server,
"overview/gan",
r#"{"totalConnections": 0, "runningConnections": 0, "outgoingByteRate": 0.0, "incomingByteRate": 0.0, "remoteGateways": 0}"#
.to_string(),
)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_status(&config, "gan");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(
body["data"]["status"]["totalConnections"],
Value::Number(0.into())
);
assert_eq!(
body["data"]["status"]["runningConnections"],
Value::Number(0.into())
);
assert_eq!(
body["data"]["status"]["outgoingByteRate"],
serde_json::json!(0.0)
);
assert_eq!(
body["data"]["status"]["incomingByteRate"],
serde_json::json!(0.0)
);
assert_eq!(
body["data"]["status"]["remoteGateways"],
Value::Number(0.into())
);
}
#[tokio::test]
async fn reads_keep_existing_error_classes() {
for family in ["license", "redundancy", "gan"] {
for (status, exit, slug) in [(404u16, 6i32, "not_found"), (401, 5, "auth_rejected")] {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(status)
.set_body_string("<html>HTTP ERROR</html>"),
)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_status(&config, family);
assert_eq!(
out.status.code(),
Some(exit),
"{family} status {status} must exit {exit} ({slug}); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope = stderr_envelope(&out);
assert_eq!(
envelope["error"]["code"],
Value::String(slug.to_string()),
"{family} status {status}"
);
}
}
}
#[derive(Clone)]
struct BundleStatusScript {
hits: Arc<Mutex<usize>>,
states: Vec<String>,
}
impl BundleStatusScript {
fn new(states: &[&str]) -> Self {
Self {
hits: Arc::new(Mutex::new(0)),
states: states.iter().map(|s| s.to_string()).collect(),
}
}
fn hits(&self) -> usize {
*self.hits.lock().unwrap()
}
}
impl wiremock::Respond for BundleStatusScript {
fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
let mut hits = self.hits.lock().unwrap();
*hits += 1;
let state = self
.states
.get(*hits - 1)
.unwrap_or_else(|| self.states.last().expect("at least one state"));
let mut body = serde_json::json!({ "state": state });
if state == "Valid" {
body["fileSize"] = serde_json::json!(61053);
}
wiremock::ResponseTemplate::new(200).set_body_json(body)
}
}
#[tokio::test]
async fn bundle_generate_status_envelope() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/api/v1/diagnostics/bundle/generate",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(r#"{"state":"Valid"}"#))
.expect(1)
.mount(&server)
.await;
mount_get(
&server,
"diagnostics/bundle/status",
r#"{"state":"Valid","fileSize":61053}"#.to_string(),
)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_cmd(&config, &["diagnostics", "bundle", "generate"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["profile"], Value::String("dev".into()));
assert_eq!(
body["data"]["state"],
Value::String("Valid".into()),
"data IS the wire — data.state carries the captured vocabulary"
);
let out = ign_cmd(&config, &["diagnostics", "bundle", "status"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["data"]["state"], Value::String("Valid".into()));
assert_eq!(
body["data"]["fileSize"],
Value::Number(61_053.into()),
"fileSize (bytes) rides the gateway-native key"
);
}
#[tokio::test]
async fn bundle_wait_flips_to_terminal() {
let server = wiremock::MockServer::start().await;
let script = BundleStatusScript::new(&["Generating", "Generating", "Valid"]);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/diagnostics/bundle/status",
))
.respond_with(script.clone())
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_cmd(
&config,
&[
"diagnostics",
"bundle",
"wait",
"--interval",
"1",
"--timeout",
"30",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
assert_eq!(body["data"]["state"], Value::String("Valid".into()));
assert_eq!(body["data"]["fileSize"], Value::Number(61_053.into()));
assert!(
script.hits() >= 3,
"at least the three scripted polls ran: {}",
script.hits()
);
}
#[tokio::test]
async fn bundle_wait_deadline_is_network() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/diagnostics/bundle/status",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_string(r#"{"state":"Generating"}"#),
)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_cmd(
&config,
&[
"diagnostics",
"bundle",
"wait",
"--interval",
"1",
"--timeout",
"2",
],
);
assert_eq!(out.status.code(), Some(4), "deadline = exit 4");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope = stderr_envelope(&out);
assert_eq!(
envelope["error"]["code"],
Value::String("network_error".into()),
"the deadline convention — NO new slug"
);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("diagnostics bundle generation"),
"the subject names the wait: {message}"
);
assert!(
message.contains("Generating"),
"the last observation rides the message verbatim: {message}"
);
assert!(
!message.contains("unreachable"),
"the gateway answered — never claim unreachability (09-07): {message}"
);
}
#[tokio::test]
async fn bundle_wait_invalid_exits_immediately() {
let server = wiremock::MockServer::start().await;
let script = BundleStatusScript::new(&["Invalid"]);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/diagnostics/bundle/status",
))
.respond_with(script.clone())
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let out = ign_cmd(
&config,
&[
"diagnostics",
"bundle",
"wait",
"--interval",
"1",
"--timeout",
"30",
],
);
assert_eq!(
out.status.code(),
Some(6),
"Invalid = exit 6 target state; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope = stderr_envelope(&out);
assert_eq!(
envelope["error"]["code"],
Value::String("bundle_not_available".into()),
"the Three-Place slug rides the envelope"
);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("Invalid"),
"the observed state is named: {message}"
);
assert!(
message.contains("generate"),
"the fresh-generate fix is named: {message}"
);
assert_eq!(
script.hits(),
1,
"EXACTLY one status hit — immediate exit, no further polls"
);
}
const BUNDLE_ZIP_BYTES: &[u8] = b"PK\x03\x04 diagnostics bundle body bytes \xDE\xAD\xBE\xEF";
async fn mount_download(server: &wiremock::MockServer, content_disposition: Option<&str>) {
let mut response = wiremock::ResponseTemplate::new(200)
.set_body_bytes(BUNDLE_ZIP_BYTES)
.append_header("Content-Type", "application/zip;charset=utf-8");
if let Some(disposition) = content_disposition {
response = response.append_header("Content-Disposition", disposition);
}
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/diagnostics/bundle/download",
))
.respond_with(response)
.mount(server)
.await
}
#[tokio::test]
async fn bundle_download_bytes_and_default_name() {
let server = wiremock::MockServer::start().await;
mount_download(&server, None).await;
let (_dir, config) = isolated_config();
write_profile_config(&config, &server.uri());
let workdir = tempfile::tempdir().expect("scratch cwd");
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", "ign-contract-test:0123456789abcdef")
.env_remove("IGNITION_URL")
.env_remove("IGNITION_PROFILE")
.args(["diagnostics", "bundle", "download", "--json"])
.current_dir(workdir.path());
let out = command.output().expect("spawn ign");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true));
let file = body["data"]["file"].as_str().expect("file name");
assert!(
file.starts_with("ignition-diagnostics-bundle-") && file.ends_with(".zip"),
"the default timestamped name applied: {file}"
);
assert_eq!(
body["data"]["bytes"],
Value::Number(BUNDLE_ZIP_BYTES.len().into()),
"byte count is the response body length"
);
assert_eq!(
body["data"]["content_type"],
Value::String("application/zip;charset=utf-8".into())
);
let written = std::fs::read(workdir.path().join(file)).expect("downloaded file");
assert_eq!(&written[..4], b"PK\x03\x04", "ZIP magic intact");
assert_eq!(written, BUNDLE_ZIP_BYTES, "byte-for-byte verbatim");
let server2 = wiremock::MockServer::start().await;
mount_download(&server2, Some("attachment; filename=\"diag.zip\"")).await;
let (_dir2, config2) = isolated_config();
write_profile_config(&config2, &server2.uri());
let workdir2 = tempfile::tempdir().expect("scratch cwd 2");
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &config2)
.env("IGNITION_TOKEN", "ign-contract-test:0123456789abcdef")
.env_remove("IGNITION_URL")
.env_remove("IGNITION_PROFILE")
.args(["diagnostics", "bundle", "download", "--json"])
.current_dir(workdir2.path());
let out = command.output().expect("spawn ign");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body: Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("envelope parses");
assert_eq!(
body["data"]["file"],
Value::String("diag.zip".into()),
"the disposition basename wins over the default"
);
assert!(workdir2.path().join("diag.zip").exists(), "file landed");
}
#[tokio::test]
async fn bundle_download_timeout_override_present() {
assert_eq!(
ignition_core::client::diagnostics::BUNDLE_DOWNLOAD_TIMEOUT,
std::time::Duration::from_secs(300),
"the per-request override is 300 s — the 30 s client default would truncate bundles"
);
}