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)
}
async fn mount_import(server: &wiremock::MockServer, project: &str) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(format!(
"/data/api/v1/projects/import/{project}"
)))
.and(wiremock::matchers::query_param("overwrite", "true"))
.and(wiremock::matchers::header(
"content-type",
"application/zip",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"success": true})),
)
.expect(1..)
.mount(server)
.await;
}
async fn mount_probe_present(server: &wiremock::MockServer, route: &str, version: &str) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(format!(
"/system/webdev/ign-cli/cli/{route}"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"data": {"routeVersion": version, "minCli": "1.0"},
})),
)
.expect(1..)
.mount(server)
.await;
}
async fn mount_probe_status(server: &wiremock::MockServer, route: &str, status: u16) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(format!(
"/system/webdev/ign-cli/cli/{route}"
)))
.respond_with(wiremock::ResponseTemplate::new(status))
.expect(1..)
.mount(server)
.await;
}
const ALWAYS_ON: [&str; 4] = ["tags", "tagConfig", "alarms", "tagHistory"];
#[tokio::test]
async fn webdev_deploy_golden() {
let server = wiremock::MockServer::start().await;
mount_import(&server, "ign%2Dcli").await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["webdev", "deploy", "--json"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
{
"ok": true,
"profile": "dev",
"data": {
"project": "ign-cli",
"routes": [
"tags",
"tagConfig",
"alarms",
"tagHistory"
],
"script_exec": false,
"secret_rotated": false,
"import": {
"success": true
}
}
}
"#]],
);
let out = ign(&config, &server.uri(), &["webdev", "deploy"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
deployed 4 routes to project ign-cli (overwrite import)
routes: tags, tagConfig, alarms, tagHistory
import: {"success":true}
"#]],
);
}
#[tokio::test]
async fn webdev_deploy_with_script_exec_golden_and_redaction() {
let server = wiremock::MockServer::start().await;
mount_import(&server, "ign%2Dcli").await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&["webdev", "deploy", "--with-script-exec", "--compact"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"project":"ign-cli","routes":["tags","tagConfig","alarms","tagHistory","scriptExec"],"script_exec":true,"secret_rotated":true,"import":{"success":true}}}"#]],
);
let out = ign(
&config,
&server.uri(),
&["webdev", "deploy", "--with-script-exec"],
);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
deployed 5 routes to project ign-cli (overwrite import)
routes: tags, tagConfig, alarms, tagHistory, scriptExec
scriptExec: deployed (reusing the stored profile secret)
import: {"success":true}
"#]],
);
let stored = stored_secret(&config).expect("secret persisted by run 1");
let all_output = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(!all_output.contains(&stored), "redaction: secret leaked");
}
#[tokio::test]
async fn webdev_status_all_present_golden() {
let server = wiremock::MockServer::start().await;
for route in ALWAYS_ON {
mount_probe_present(&server, route, ignition_core::webdev::ROUTE_BUNDLE_VERSION).await;
}
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["webdev", "status"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
tags present 1.3.0
tagConfig present 1.3.0
alarms present 1.3.0
tagHistory present 1.3.0
ok: all always-on routes present with matching versions
"#]],
);
let out = ign(&config, &server.uri(), &["webdev", "status", "--compact"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"project":"ign-cli","routes":[{"route":"tags","status":"present","deployed_version":"1.3.0","expected_version":"1.3.0"},{"route":"tagConfig","status":"present","deployed_version":"1.3.0","expected_version":"1.3.0"},{"route":"alarms","status":"present","deployed_version":"1.3.0","expected_version":"1.3.0"},{"route":"tagHistory","status":"present","deployed_version":"1.3.0","expected_version":"1.3.0"}],"ok":true}}"#]],
);
}
#[tokio::test]
async fn webdev_status_absent_is_data_exit_zero() {
let server = wiremock::MockServer::start().await;
for route in ALWAYS_ON {
mount_probe_status(&server, route, 405).await;
}
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["webdev", "status", "--compact"]);
assert!(
out.status.success(),
"status with absent routes EXITS 0 (degradation is data); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let envelope: Value = serde_json::from_str(stdout_for_golden(&out)).expect("envelope parses");
assert_eq!(envelope["ok"], true, "the SWEEP succeeded: {envelope}");
assert_eq!(envelope["data"]["ok"], false, "the verdict is data");
for route in ALWAYS_ON {
let row = envelope["data"]["routes"]
.as_array()
.unwrap()
.iter()
.find(|row| row["route"] == route)
.unwrap_or_else(|| panic!("{route} row"));
assert_eq!(row["status"], "absent", "{route}: {row}");
assert_eq!(row["deployed_version"], Value::Null);
assert_eq!(
row["expected_version"],
ignition_core::webdev::ROUTE_BUNDLE_VERSION
);
}
let out = ign(&config, &server.uri(), &["webdev", "status"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
tags absent -
tagConfig absent -
alarms absent -
tagHistory absent -
degraded: run `ign webdev deploy` to install/refresh the routes
"#]],
);
}
#[tokio::test]
async fn webdev_status_version_mismatch_rows() {
let server = wiremock::MockServer::start().await;
mount_probe_present(&server, "tags", "0.9.0").await;
for route in &ALWAYS_ON[1..] {
mount_probe_present(&server, route, ignition_core::webdev::ROUTE_BUNDLE_VERSION).await;
}
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["webdev", "status", "--compact"]);
assert!(out.status.success());
let envelope: Value = serde_json::from_str(stdout_for_golden(&out)).expect("envelope parses");
assert_eq!(envelope["data"]["ok"], false);
let tags = envelope["data"]["routes"]
.as_array()
.unwrap()
.iter()
.find(|row| row["route"] == "tags")
.unwrap();
assert_eq!(tags["status"], "version_mismatch");
assert_eq!(tags["deployed_version"], "0.9.0");
assert_eq!(
tags["expected_version"],
ignition_core::webdev::ROUTE_BUNDLE_VERSION
);
}
fn stored_secret(config: &Path) -> Option<String> {
ignition_core::config::load(config)
.expect("config reloads")
.profiles
.get("dev")
.and_then(|profile| profile.webdev_secret.clone())
}