use std::path::{Path, PathBuf};
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}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write config");
}
fn ign(config: &Path, url: &str, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url);
command.args(args).output().expect("spawn ign")
}
fn stdout_for_golden(out: &std::process::Output) -> &str {
let stdout = std::str::from_utf8(&out.stdout).expect("utf-8 stdout");
stdout.strip_suffix('\n').unwrap_or(stdout)
}
fn healthy_gateway_info() -> serde_json::Value {
serde_json::json!({
"name": "GW",
"redundancyRole": "Independent",
"edition": "standard",
"ignitionVersion": "8.3.6 (b2026042713)"
})
}
fn security_properties_body() -> serde_json::Value {
serde_json::json!({
"type": "ignition/security-properties",
"signature": "dee8c946",
"collection": "core",
"config": {
"readPermissions": {"type": "AnyOf", "securityLevels": [{"name": "Authenticated", "children": [{"name": "Roles", "children": [{"name": "Administrator", "children": []}]}]}]},
"writePermissions": {"type": "AnyOf", "securityLevels": [{"name": "Authenticated", "children": [{"name": "Roles", "children": [{"name": "Administrator", "children": []}]}]}]}
}
})
}
fn jetty_error_html(status: u16, uri: &str) -> Vec<u8> {
let message = match status {
401 => "Unauthorized",
403 => "Forbidden",
_ => "Error",
};
format!(
concat!(
r#"<html><head><meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/>"#,
r#"<title>Error {status}</title></head><body><h2>HTTP ERROR {status} {message}</h2><table>"#,
r#"<tr><th>URI:</th><td>{uri}</td></tr><tr><th>STATUS:</th><td>{status}</td></tr>"#,
r#"<tr><th>MESSAGE:</th><td>{message}</td></tr></table></body></html>"#
),
status = status,
message = message,
uri = uri,
)
.into_bytes()
}
#[tokio::test]
async fn doctor_healthy_gateway_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(healthy_gateway_info()))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/resources/singleton/ignition/security-properties",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(security_properties_body()),
)
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["doctor"]);
assert!(
out.status.success(),
"diagnosis completes = exit 0: stderr {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
url OK TCP connect to 127.0.0.1:[..] succeeded
liveness OK gateway RUNNING (unauthenticated /StatusPing)
commissioned OK no /welcome redirect on /data routes
auth OK gateway-info read succeeded (HTTP 200, gateway 8.3.6 (b2026042713))
permissions OK readPermissions: {"securityLevels":[{"children":[{"children":[{"children":[],"name":"Administrator"}],"name":"Roles"}],"name":"Authenticated"}],"type":"AnyOf"}; writePermissions: {"securityLevels":[{"children":[{"children":[{"children":[],"name":"Administrator"}],"name":"Roles"}],"name":"Authenticated"}],"type":"AnyOf"}
write SKIP not requested (--check-write)
webdev SKIP not requested (--webdev-route NAME)
rig [..]
8 checks: [..]
"#]],
);
}
#[tokio::test]
async fn doctor_401_names_the_name_key_format_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_raw(
jetty_error_html(401, "/data/api/v1/gateway-info"),
"text/html;charset=iso-8859-1",
))
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["doctor"]);
assert!(
out.status.success(),
"failing checks are data, not CLI errors: exit 0"
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
url OK TCP connect to 127.0.0.1:[..] succeeded
liveness OK gateway RUNNING (unauthenticated /StatusPing)
commissioned OK no /welcome redirect on /data routes
auth FAIL token not recognized (HTTP 401 on gateway-info)
hint: the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token
permissions SKIP auth read failed — the security-properties read needs a working token
write SKIP not requested (--check-write)
webdev SKIP not requested (--webdev-route NAME)
rig [..]
8 checks: [..]
"#]],
);
}
#[tokio::test]
async fn doctor_403_three_part_hint_and_permissions_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_raw(
jetty_error_html(403, "/data/api/v1/gateway-info"),
"text/html;charset=iso-8859-1",
))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/resources/singleton/ignition/security-properties",
))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_raw(
jetty_error_html(
403,
"/data/api/v1/resources/singleton/ignition/security-properties",
),
"text/html;charset=iso-8859-1",
))
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["doctor"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
url OK TCP connect to 127.0.0.1:[..] succeeded
liveness OK gateway RUNNING (unauthenticated /StatusPing)
commissioned OK no /welcome redirect on /data routes
auth FAIL token recognized but under-permitted (HTTP 403 on gateway-info)
hint: Ignition token setup is three parts: (1) the token holds an adequate security level, (2) the gateway's read/write permissions include that level (default: only Authenticated/Roles/Administrator), (3) 'Require secure connections' is unchecked for http gateways — the permissions row below helps with part 2
permissions WARN this token cannot read security-properties either (HTTP 403) — the gateway's read/write permissions likely exclude the token's security level (three-part cause 2)
write SKIP not requested (--check-write)
webdev SKIP not requested (--webdev-route NAME)
rig [..]
8 checks: [..]
"#]],
);
}
#[tokio::test]
async fn doctor_uncommissioned_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(
wiremock::ResponseTemplate::new(302).insert_header("Location", "/welcome#/home"),
)
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["doctor"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
url OK TCP connect to 127.0.0.1:[..] succeeded
liveness OK gateway RUNNING (unauthenticated /StatusPing)
commissioned FAIL every /data route redirects to /welcome — gateway not commissioned
hint: open http://<host>:<port>/welcome in a browser and complete the commissioning wizard
auth SKIP gateway not commissioned — auth not assessable
permissions SKIP auth read failed — the security-properties read needs a working token
write SKIP not requested (--check-write)
webdev SKIP not requested (--webdev-route NAME)
rig [..]
8 checks: [..]
"#]],
);
}
#[tokio::test]
async fn doctor_json_shape_and_flags() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(healthy_gateway_info()))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/resources/singleton/ignition/security-properties",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(security_properties_body()),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/scan/projects"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/system/webdev/ign-cli/cli/stacked",
))
.respond_with(wiremock::ResponseTemplate::new(405))
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"doctor",
"--check-write",
"--webdev-route",
"stacked",
"--compact",
],
);
assert!(out.status.success());
let body: Value = serde_json::from_slice(&out.stdout).expect("envelope parses");
let checks = body["data"]["checks"].as_array().expect("checks[] present");
let names: Vec<&str> = checks
.iter()
.map(|check| check["name"].as_str().expect("name"))
.collect();
assert_eq!(
names,
vec![
"url",
"liveness",
"commissioned",
"auth",
"permissions",
"write",
"webdev",
"rig"
],
"documented order"
);
for check in checks {
let mut keys = check
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect::<Vec<_>>();
keys.sort_unstable();
assert_eq!(
keys,
vec!["detail", "hint", "name", "status"],
"exactly the four contract keys"
);
}
let by_name = |name: &str| {
checks
.iter()
.find(|check| check["name"] == name)
.unwrap_or_else(|| panic!("{name} row"))
.clone()
};
assert_eq!(by_name("write")["status"], Value::String("ok".into()));
assert!(
by_name("write")["detail"]
.as_str()
.expect("detail")
.contains("write permitted"),
);
assert_eq!(by_name("webdev")["status"], Value::String("warn".into()));
assert!(
by_name("webdev")["detail"]
.as_str()
.expect("detail")
.contains("absent"),
"405 = route absent (warn)"
);
assert_eq!(by_name("url")["hint"], Value::Null, "hint null-able");
let rig = by_name("rig");
let rig_status = rig["status"].as_str().expect("rig status").to_string();
assert!(
rig_status == "ok" || rig_status == "skip",
"rig row machine-dependent: {rig_status}"
);
}
#[tokio::test]
async fn doctor_healthy_json_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(healthy_gateway_info()))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/resources/singleton/ignition/security-properties",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(security_properties_body()),
)
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["doctor", "--json"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
{
"ok": true,
"profile": "dev",
"data": {
"checks": [
{
"name": "url",
"status": "ok",
"detail": "TCP connect to 127.0.0.1:[..] succeeded",
"hint": null
},
{
"name": "liveness",
"status": "ok",
"detail": "gateway RUNNING (unauthenticated /StatusPing)",
"hint": null
},
{
"name": "commissioned",
"status": "ok",
"detail": "no /welcome redirect on /data routes",
"hint": null
},
{
"name": "auth",
"status": "ok",
"detail": "gateway-info read succeeded (HTTP 200, gateway [..])",
"hint": null
},
{
"name": "permissions",
"status": "ok",
"detail": "readPermissions: {/"securityLevels/":[{/"children/":[{/"children/":[{/"children/":[],/"name/":/"Administrator/"}],/"name/":/"Roles/"}],/"name/":/"Authenticated/"}],/"type/":/"AnyOf/"}; writePermissions: {/"securityLevels/":[{/"children/":[{/"children/":[{/"children/":[],/"name/":/"Administrator/"}],/"name/":/"Roles/"}],/"name/":/"Authenticated/"}],/"type/":/"AnyOf/"}",
"hint": null
},
{
"name": "write",
"status": "skip",
"detail": "not requested (--check-write)",
"hint": null
},
{
"name": "webdev",
"status": "skip",
"detail": "not requested (--webdev-route NAME)",
"hint": null
},
{
"name": "rig",
"status": "[..]",
"detail": "[..]",
"hint": [..]
}
]
}
}
"#]],
);
}