mod common;
use assert_cmd::Command;
const EXPECTED_JSON_STDOUT: &[u8] = b"{\n \"ok\": true,\n \"profile\": null,\n \"data\": {\n \"active\": null,\n \"profiles\": []\n }\n}\n";
const EXPECTED_HUMAN_STDOUT: &[u8] = b"";
fn noisy_ign() -> (tempfile::TempDir, Command) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(&config, "bogus_key = 1\n").expect("write noisy config");
let mut cmd = Command::cargo_bin("ign").expect("binary 'ign' not found");
cmd.env("IGNITION_CLI_CONFIG", &config);
cmd.env("IGNITION_LOG", "trace");
cmd.env_remove("IGNITION_PROFILE");
cmd.env_remove("IGNITION_JSON");
cmd.env_remove("IGNITION_YES");
(dir, cmd)
}
fn assert_stdout_bytes(actual: &[u8], expected: &[u8], label: &str) {
if actual == expected {
return;
}
let first_diff = actual
.iter()
.zip(expected.iter())
.position(|(a, e)| a != e)
.unwrap_or(actual.len().min(expected.len()));
panic!(
"{label}: stdout is not byte-pure under max diagnostics.\n first \
differing byte at offset {first_diff}\n actual len: {}, expected \
len: {}\n actual: {:?}\n expected: {:?}",
actual.len(),
expected.len(),
String::from_utf8_lossy(actual),
String::from_utf8_lossy(expected),
);
}
#[test]
fn stdout_is_byte_pure_under_max_diagnostics() {
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args(["profile", "list", "--json"])
.output()
.expect("spawn ign");
assert!(
output.status.success(),
"command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_bytes(&output.stdout, EXPECTED_JSON_STDOUT, "json mode");
let text = std::str::from_utf8(&output.stdout).expect("stdout is valid UTF-8");
serde_json::from_slice::<serde_json::Value>(&output.stdout)
.expect("stdout parses as exactly one JSON value with nothing after it");
assert!(
text.trim_end().ends_with('}'),
"stdout ends inside a JSON value, not after it: {text:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("bogus_key"),
"expected the unknown-key warning on stderr (noise precondition): {stderr}"
);
}
#[test]
fn human_stdout_is_line_pure_under_max_diagnostics() {
let (_dir, mut cmd) = noisy_ign();
let output = cmd.args(["profile", "list"]).output().expect("spawn ign");
assert!(
output.status.success(),
"command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_bytes(&output.stdout, EXPECTED_HUMAN_STDOUT, "human mode");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("bogus_key"),
"expected the unknown-key warning on stderr (noise precondition): {stderr}"
);
}
#[test]
fn tags_import_loss_prose_never_touches_stdout() {
let (_dir, mut cmd) = noisy_ign();
let dir = tempfile::tempdir().expect("tempdir");
let xml = dir.path().join("p5.xml");
std::fs::write(
&xml,
"<Tags MinVersion=\"8.0.0\" locale=\"en_US\">\r\n <Tag name=\"T1\" type=\"AtomicTag\">\r\n <Property name=\"valueSource\">memory</Property>\r\n </Tag>\r\n</Tags>\r\n",
)
.expect("write xml fixture");
let output = cmd
.args([
"tags",
"import",
"--format",
"xml",
"--file",
xml.to_str().expect("utf-8 path"),
"--provider",
"p5import",
])
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(2),
"the gate refuses exit 2 pre-resolution"
);
assert_stdout_bytes(&output.stdout, b"", "loss-gate refusal");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("loss report (xml)") && stderr.contains("re-run with --yes"),
"expected the loss report prose on stderr: {stderr}"
);
}
#[test]
fn tags_export_error_keeps_stdout_byte_empty() {
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args([
"tags",
"export",
"[default]P5",
"--format",
"xml",
"-o",
"-",
])
.output()
.expect("spawn ign");
assert_ne!(
output.status.code(),
Some(0),
"no gateway configured — must refuse"
);
assert_stdout_bytes(&output.stdout, b"", "xml export refusal");
}
#[test]
fn workspace_status_missing_manifest_refusal_never_touches_stdout() {
let (_dir, mut cmd) = noisy_ign();
let missing = tempfile::tempdir().expect("tempdir").path().join("nope");
let output = cmd
.args(["workspace", "status", missing.to_str().expect("utf-8 path")])
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(2),
"the pre-resolve refusal exits 2"
);
assert_stdout_bytes(&output.stdout, b"", "missing-manifest refusal");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not an ign workspace"),
"expected the stable manifest refusal on stderr: {stderr}"
);
}
#[test]
fn workspace_push_and_checkout_refusals_keep_stdout_byte_empty() {
let (_dir, mut cmd) = noisy_ign();
let absent = tempfile::tempdir().expect("tempdir");
let missing = absent.path().join("nope");
let output = cmd
.args(["workspace", "push", missing.to_str().expect("utf-8 path")])
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(2),
"the pre-resolve refusal exits 2"
);
assert_stdout_bytes(&output.stdout, b"", "push missing-manifest refusal");
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(
&config,
"active = \"dev\"\n\n[profiles.dev]\nurl = \"http://127.0.0.1:9\"\nauth = { token_env = \"IGNITION_TOKEN\" }\n",
)
.expect("write dead-port config");
let output = Command::cargo_bin("ign")
.expect("binary 'ign' not found")
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_LOG", "trace")
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES")
.args(["workspace", "checkout", "proj", "/tmp/ign-purity-checkout"])
.output()
.expect("spawn ign");
assert_ne!(output.status.code(), Some(0), "a dead port must refuse");
assert_stdout_bytes(&output.stdout, b"", "checkout network refusal");
}
#[test]
fn deploy_render_json_stdout_is_the_envelope_only() {
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args([
"--json",
"deploy",
"render",
"--env",
"dev",
"--repo",
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ignition-core/tests/fixtures/deploy/repos/tracer"
),
])
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = &output.stdout;
assert!(
stdout.ends_with(b"\n") && !stdout.ends_with(b"\n\n"),
"exactly one trailing newline: {:?}",
String::from_utf8_lossy(stdout)
);
let mut stream = serde_json::Deserializer::from_slice(stdout).into_iter::<serde_json::Value>();
let envelope = stream
.next()
.expect("one JSON value")
.expect("stdout parses as JSON");
assert_eq!(envelope["ok"], true);
let value_end = stream.byte_offset();
assert!(
stream.next().is_none(),
"stdout holds exactly one JSON value"
);
assert_eq!(
stdout[value_end..],
*b"\n",
"nothing but the newline follows the envelope"
);
}
#[test]
fn deploy_render_out_json_stdout_is_the_envelope_only() {
let scratch = tempfile::tempdir().expect("tempdir");
let out_dir = scratch.path().join("r");
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args([
"--json",
"deploy",
"render",
"--env",
"dev",
"--repo",
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ignition-core/tests/fixtures/deploy/repos/tracer"
),
"--out",
])
.arg(&out_dir)
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = &output.stdout;
assert!(
stdout.ends_with(b"\n") && !stdout.ends_with(b"\n\n"),
"exactly one trailing newline: {:?}",
String::from_utf8_lossy(stdout)
);
let mut stream = serde_json::Deserializer::from_slice(stdout).into_iter::<serde_json::Value>();
let envelope = stream
.next()
.expect("one JSON value")
.expect("stdout parses as JSON");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["data"]["out"], out_dir.to_string_lossy().as_ref());
let value_end = stream.byte_offset();
assert!(
stream.next().is_none(),
"stdout holds exactly one JSON value"
);
assert_eq!(stdout[value_end..], *b"\n");
assert!(out_dir.join("render.json").is_file());
}
#[test]
fn deploy_render_refusal_keeps_stdout_byte_empty() {
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args([
"--json",
"deploy",
"render",
"--env",
"nope",
"--repo",
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ignition-core/tests/fixtures/deploy/repos/tracer"
),
])
.output()
.expect("spawn ign");
assert_eq!(output.status.code(), Some(2));
assert_stdout_bytes(&output.stdout, b"", "deploy render refusal");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deploy_tree_invalid"),
"expected the slug on stderr: {stderr}"
);
}
#[test]
fn deploy_plan_json_stdout_is_the_envelope_only() {
fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).expect("mkdir");
for entry in std::fs::read_dir(from).expect("read_dir") {
let entry = entry.expect("entry");
let dest = to.join(entry.file_name());
if entry.file_type().expect("type").is_dir() {
copy_dir(&entry.path(), &dest);
} else {
std::fs::copy(entry.path(), &dest).expect("copy");
}
}
}
let scratch = tempfile::tempdir().expect("tempdir");
let repo = scratch.path().join("repo");
copy_dir(
std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ignition-core/tests/fixtures/deploy/repos/tracer"
)),
&repo,
);
let data = scratch.path().join("data");
std::fs::create_dir_all(data.join("config").join("resources")).expect("resources dir");
std::fs::write(
repo.join("targets/dev.yaml"),
format!(
"schema_version: 1\ngateway: tracer-gw\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: '{}'\n",
data.display()
),
)
.expect("target file");
let (_dir, mut cmd) = noisy_ign();
let output = cmd
.args(["--json", "deploy", "plan", "--env", "dev", "--repo"])
.arg(&repo)
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = &output.stdout;
assert!(
stdout.ends_with(b"\n") && !stdout.ends_with(b"\n\n"),
"exactly one trailing newline: {:?}",
String::from_utf8_lossy(stdout)
);
let mut stream = serde_json::Deserializer::from_slice(stdout).into_iter::<serde_json::Value>();
let envelope = stream
.next()
.expect("one JSON value")
.expect("stdout parses as JSON");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["data"]["summary"]["has_changes"], true);
let value_end = stream.byte_offset();
assert!(
stream.next().is_none(),
"stdout holds exactly one JSON value"
);
assert_eq!(
stdout[value_end..],
*b"\n",
"nothing but the newline follows the envelope"
);
}
#[tokio::test]
async fn deploy_verify_json_stdout_is_the_envelope_only() {
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).expect("mkdir");
for entry in std::fs::read_dir(from).expect("read_dir") {
let entry = entry.expect("entry");
let dest = to.join(entry.file_name());
if entry.file_type().expect("type").is_dir() {
copy_dir(&entry.path(), &dest);
} else {
std::fs::copy(entry.path(), &dest).expect("copy");
}
}
}
let scratch = tempfile::tempdir().expect("tempdir");
let repo = scratch.path().join("repo");
copy_dir(
std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ignition-core/tests/fixtures/deploy/repos/tracer"
)),
&repo,
);
let data = scratch.path().join("data");
std::fs::create_dir_all(data.join("config").join("resources")).expect("resources dir");
std::fs::write(
repo.join("targets/dev.yaml"),
format!(
"schema_version: 1\ngateway: tracer-gw\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: '{}'\n",
data.display()
),
)
.expect("target file");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/data/api/v1/scan/config"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"scanActive": false, "lastScanTimestamp": 1_792_000_000_000_i64, "lastScanDuration": 300
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/data/api/v1/gateway-info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"deploymentMode": "dev"})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(
"/data/api/v1/resources/list/ignition/database-translator",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": [], "metadata": {"total": 0, "matching": 0, "limit": -1, "offset": 0}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(
"/data/api/v1/resources/singleton/ignition/general-alarm-settings",
))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/data/api/v1/logs"))
.and(query_param("limit", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": [{"timestamp": 1_792_999_999_999_i64, "loggerName": "x", "level": "INFO", "message": "m"}],
"metadata": {"total": 1, "matching": 1, "limit": 1, "offset": 0}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/data/api/v1/logs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": [], "metadata": {"total": 0, "matching": 0, "limit": 200, "offset": 0}
})))
.mount(&server)
.await;
let config = scratch.path().join("config.toml");
std::fs::write(
&config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
server.uri()
),
)
.expect("write config");
let mut cmd = Command::cargo_bin("ign").expect("binary 'ign' not found");
cmd.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_LOG", "trace")
.env_remove("IGNITION_URL")
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES");
let output = cmd
.args(["--json", "deploy", "verify", "--env", "dev", "--repo"])
.arg(&repo)
.output()
.expect("spawn ign");
assert_eq!(
output.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = &output.stdout;
assert!(
stdout.ends_with(b"\n") && !stdout.ends_with(b"\n\n"),
"exactly one trailing newline: {:?}",
String::from_utf8_lossy(stdout)
);
let mut stream = serde_json::Deserializer::from_slice(stdout).into_iter::<serde_json::Value>();
let envelope = stream
.next()
.expect("one JSON value")
.expect("stdout parses as JSON");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["data"]["ok"], false);
assert_eq!(envelope["data"]["slug"], "deploy_verify_failed");
let value_end = stream.byte_offset();
assert!(
stream.next().is_none(),
"stdout holds exactly one JSON value"
);
assert_eq!(
stdout[value_end..],
*b"\n",
"nothing but the newline follows the envelope"
);
}
fn noisy_gateway_ign(url: &str) -> (tempfile::TempDir, Command) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(
&config,
format!(
"bogus_key = 1\nactive = \"dev\"\n\n[profiles.dev]\nurl = \"{url}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write noisy config");
let mut cmd = Command::cargo_bin("ign").expect("binary 'ign' not found");
cmd.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_LOG", "trace");
for var in [
"IGNITION_URL",
"IGNITION_PROFILE",
"IGNITION_JSON",
"IGNITION_YES",
] {
cmd.env_remove(var);
}
(dir, cmd)
}
fn assert_one_envelope(stdout: &[u8], label: &str) -> serde_json::Value {
assert!(
stdout.ends_with(b"\n") && !stdout.ends_with(b"\n\n"),
"{label}: exactly one trailing newline: {:?}",
String::from_utf8_lossy(stdout)
);
let mut stream = serde_json::Deserializer::from_slice(stdout).into_iter::<serde_json::Value>();
let envelope = stream
.next()
.unwrap_or_else(|| panic!("{label}: one JSON value"))
.unwrap_or_else(|e| panic!("{label}: stdout parses as JSON: {e}"));
let value_end = stream.byte_offset();
assert!(
stream.next().is_none(),
"{label}: stdout holds exactly one JSON value"
);
assert_eq!(
stdout[value_end..],
*b"\n",
"{label}: nothing but the newline follows the envelope"
);
envelope
}
fn apply_cmd(
server: &wiremock::MockServer,
repo: &std::path::Path,
hash: &str,
yes: bool,
) -> std::process::Output {
let (_cfg, mut cmd) = noisy_gateway_ign(&server.uri());
cmd.args(common::apply_args(repo, Some(hash), true, yes))
.output()
.expect("spawn ign")
}
fn rollback_cmd(
server: &wiremock::MockServer,
repo: &std::path::Path,
run: &str,
) -> std::process::Output {
let (_cfg, mut cmd) = noisy_gateway_ign(&server.uri());
cmd.args(common::rollback_args(repo, run, true, true))
.output()
.expect("spawn ign")
}
async fn applied_run(server: &wiremock::MockServer, repo: &std::path::Path) -> String {
common::apply_through_binary(server, repo, &[]).await
}
#[tokio::test]
async fn deploy_apply_json_stdout_is_the_envelope_only() {
use serde_json::json;
let (_scratch, repo, data) = common::apply_tracer();
let hash = common::plan_hash(&repo);
let server = wiremock::MockServer::start().await;
common::mount_apply_gateway(&server, &data, Vec::new()).await;
let out = apply_cmd(&server, &repo, &hash, true);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let envelope = assert_one_envelope(&out.stdout, "apply applied");
assert_eq!(envelope["data"]["outcome"], "applied");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("bogus_key"),
"the noise really fired (precondition): {stderr}"
);
let (_scratch, repo, data) = common::apply_tracer();
let hash = common::plan_hash(&repo);
let server = wiremock::MockServer::start().await;
common::mount_apply_gateway(&server, &data, Vec::new()).await;
let out = apply_cmd(&server, &repo, &hash, false);
assert_eq!(out.status.code(), Some(2));
assert_stdout_bytes(&out.stdout, b"", "apply confirmation refusal");
assert!(String::from_utf8_lossy(&out.stderr).contains("confirmation_required"));
let (_scratch, repo, data) = common::apply_tracer();
let hash = common::plan_hash(&repo);
let server = wiremock::MockServer::start().await;
common::mount_apply_gateway(
&server,
&data,
vec![json!({
"timestamp": 1_792_999_999_000_i64,
"loggerName": "NamedResourceHandler-ignition/database-translator",
"level": "WARN",
"message": format!(
"Resource {} does not have required UUID attribute, will not be loaded",
common::NAMED_TRANSLATOR
),
})],
)
.await;
let out = apply_cmd(&server, &repo, &hash, true);
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let envelope = assert_one_envelope(&out.stdout, "apply rolled back");
assert_eq!(envelope["data"]["outcome"], "rolled_back");
assert_eq!(envelope["data"]["slug"], "deploy_rolled_back");
}
#[tokio::test]
async fn deploy_rollback_json_stdout_is_the_envelope_only() {
use ignition_core::deploy::apply::journal::JournalPhase;
let (_scratch, repo, data) = common::apply_tracer();
let server = wiremock::MockServer::start().await;
common::mount_apply_gateway(&server, &data, Vec::new()).await;
let run = applied_run(&server, &repo).await;
let out = rollback_cmd(&server, &repo, &run);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let envelope = assert_one_envelope(&out.stdout, "rollback restored");
assert_eq!(envelope["data"]["outcome"], "restored");
assert!(
String::from_utf8_lossy(&out.stderr).contains("bogus_key"),
"the noise really fired (precondition)"
);
common::plant_journal_at(&data, &run, JournalPhase::Ready);
let out = rollback_cmd(&server, &repo, &run);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let envelope = assert_one_envelope(&out.stdout, "rollback cleared");
assert_eq!(envelope["data"]["outcome"], "cleared");
let (_scratch, repo, data) = common::apply_tracer();
let server = wiremock::MockServer::start().await;
common::mount_apply_gateway(&server, &data, Vec::new()).await;
let run = applied_run(&server, &repo).await;
common::edit_live_unit(&data);
let out = rollback_cmd(&server, &repo, &run);
assert_eq!(out.status.code(), Some(6));
assert_stdout_bytes(&out.stdout, b"", "rollback live-changed refusal");
assert!(String::from_utf8_lossy(&out.stderr).contains("deploy_live_changed"));
}
fn projects_repo() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
use std::fs::{create_dir_all, write};
let scratch = tempfile::tempdir().expect("tempdir");
let repo = scratch.path().join("repo");
let targets = scratch.path().join("targets-root");
let data = scratch.path().join("data");
let put = |root: &std::path::Path, rel: &str, bytes: &[u8]| {
let file = root.join(rel);
create_dir_all(file.parent().expect("parent")).expect("mkdir");
write(file, bytes).expect("write");
};
create_dir_all(data.join("projects")).expect("data dir");
put(
&repo,
"project.json",
br#"{"title":"Cap App","enabled":true}"#,
);
put(
&repo,
"deploy/project.yaml",
b"schema_version: 1\nproject: CapApp\nenvironments: [dev]\n",
);
put(
&repo,
"ignition/script-python/cap/resource.json",
br#"{"scope":"A","files":["code.py"],"attributes":{"enabled":true}}"#,
);
put(
&repo,
"ignition/script-python/cap/code.py",
b"def f():\n return 1\n",
);
put(&repo, ".gitignore", b".ign/\n");
put(
&data,
"config/resources/core/ignition/system-properties/config.json",
br#"{"systemName":"capgw"}"#,
);
put(
&targets,
"targets/dev.yaml",
format!(
"schema_version: 1\ngateway: capgw\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: '{}'\n",
data.display()
)
.as_bytes(),
);
let status = std::process::Command::new("git")
.args(["init", "-q"])
.arg(&repo)
.status()
.expect("git runs");
assert!(status.success());
(scratch, repo, targets)
}
async fn mount_projects_gateway(server: &wiremock::MockServer, repo: &std::path::Path) -> String {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
let artifact =
ignition_core::deploy::project_artifact::build_artifact(repo).expect("the fixture builds");
let get = |route: &str, response: ResponseTemplate| {
Mock::given(method("GET"))
.and(path(route.to_string()))
.respond_with(response)
};
get(
"/data/api/v1/gateway-info",
ResponseTemplate::new(200).set_body_json(json!({
"name": "capgw",
"deploymentMode": "dev",
"ignitionVersion": "8.3.6 (b2026042713)"
})),
)
.mount(server)
.await;
get(
"/data/api/v1/designers",
ResponseTemplate::new(200).set_body_json(json!({
"items": [],
"metadata": {"total": 0, "matching": 0, "limit": -1, "offset": 0}
})),
)
.mount(server)
.await;
get(
"/data/api/v1/projects/list",
ResponseTemplate::new(200).set_body_json(json!({
"items": [],
"metadata": {"total": 0, "matching": 0, "limit": -1, "offset": 0}
})),
)
.mount(server)
.await;
get(
"/data/api/v1/projects/find/CapApp",
ResponseTemplate::new(404).set_body_json(json!({"message": "Project not found"})),
)
.mount(server)
.await;
get(
"/data/api/v1/projects/export/CapApp",
ResponseTemplate::new(200).set_body_raw(artifact.zip, "application/zip"),
)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/data/api/v1/projects/import/CapApp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"success": true, "changes": [{"name": "CapApp"}]})),
)
.mount(server)
.await;
artifact.digest.sha256
}
#[tokio::test]
async fn projects_stdout_one_envelope() {
for (confirmed, outcome) in [(false, "checked"), (true, "created")] {
let (_scratch, repo, targets) = projects_repo();
let server = wiremock::MockServer::start().await;
let hash = mount_projects_gateway(&server, &repo).await;
let (_cfg, mut cmd) = noisy_gateway_ign(&server.uri());
cmd.args([
"--json",
"deploy",
"projects",
"--env",
"dev",
"--release",
"v1.0.0",
"--repo",
])
.arg(&repo)
.arg("--targets")
.arg(&targets);
if confirmed {
cmd.args(["--artifact-sha256", &hash, "--yes"]);
} else {
cmd.arg("--check");
}
let out = cmd.output().expect("spawn ign");
let label = if confirmed {
"projects apply"
} else {
"projects check"
};
assert_eq!(
out.status.code(),
Some(0),
"{label}: {}",
String::from_utf8_lossy(&out.stderr)
);
let envelope = assert_one_envelope(&out.stdout, label);
assert_eq!(envelope["ok"], true, "{label}");
assert_eq!(envelope["data"]["outcome"], outcome, "{label}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("bogus_key"),
"{label}: the noise really fired (precondition): {stderr}"
);
}
}