mod common;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use common::verify_ign;
use serde_json::{Value, json};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const SCRIPT_RESOURCE: &str = r#"{"scope":"A","files":["code.py"],"attributes":{"enabled":true}}"#;
const CODE_V1: &[u8] = b"def f():\n return 1\n";
const CODE_V2: &[u8] = b"def f():\n return 2\n";
fn put(root: &Path, rel: &str, bytes: &[u8]) {
let target = root.join(rel);
std::fs::create_dir_all(target.parent().expect("parent")).expect("mkdir");
std::fs::write(target, bytes).expect("write");
}
struct Fixture {
_scratch: tempfile::TempDir,
repo: PathBuf,
targets: PathBuf,
data: PathBuf,
}
fn fixture() -> Fixture {
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");
std::fs::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",
SCRIPT_RESOURCE.as_bytes(),
);
put(&repo, "ignition/script-python/cap/code.py", CODE_V1);
put(&repo, "docs/notes.md", b"not shipped");
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(),
);
Fixture {
_scratch: scratch,
repo,
targets,
data,
}
}
fn export_zip(code: &[u8], stamp: bool) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
let project = if stamp {
r#"{"title":"Cap App","enabled":true,"description":"","parent":""}"#
} else {
r#"{"title":"Cap App","enabled":true}"#
};
let resource = if stamp {
r#"{"scope":"A","files":["code.py"],"attributes":{"enabled":true,"lastModification":{"actor":"admin","timestamp":"2026-10-22T10:00:00Z"},"lastModificationSignature":"abc"}}"#
} else {
SCRIPT_RESOURCE
};
for (name, bytes) in [
("project.json", project.as_bytes()),
(
"ignition/script-python/cap/resource.json",
resource.as_bytes(),
),
("ignition/script-python/cap/code.py", code),
] {
writer.start_file(name, opts).expect("start");
writer.write_all(bytes).expect("write");
}
writer.finish().expect("finish").into_inner()
}
async fn mount_list(server: &MockServer, names: &[&str]) {
let items: Vec<Value> = names
.iter()
.map(|n| json!({"name": n, "enabled": true}))
.collect();
let total = items.len();
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": items,
"metadata": {"total": total, "matching": total, "limit": -1, "offset": 0}
})))
.mount(server)
.await;
}
async fn mount_find_found(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/find/CapApp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"name": "CapApp", "title": "Cap App", "enabled": true})),
)
.mount(server)
.await;
}
async fn mount_find_missing(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/find/CapApp"))
.respond_with(
ResponseTemplate::new(404).set_body_json(json!({"message": "Project not found"})),
)
.mount(server)
.await;
}
async fn mount_export(server: &MockServer, zip: Vec<u8>) {
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/export/CapApp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(zip, "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"CapApp-export.zip\"",
),
)
.mount(server)
.await;
}
fn check_args(fx: &Fixture, env: &str) -> Vec<String> {
vec![
"--json".into(),
"deploy".into(),
"projects".into(),
"--env".into(),
env.into(),
"--release".into(),
"v1.0.0".into(),
"--check".into(),
"--repo".into(),
fx.repo.display().to_string(),
"--targets".into(),
fx.targets.display().to_string(),
]
}
fn run(server: &MockServer, args: &[String]) -> std::process::Output {
let (_dir, mut cmd) = verify_ign(&server.uri());
cmd.args(args).output().expect("spawn ign")
}
fn data_of(out: &std::process::Output) -> Value {
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let envelope: Value = serde_json::from_slice(&out.stdout).expect("envelope");
assert_eq!(envelope["ok"], true);
envelope["data"].clone()
}
async fn methods(server: &MockServer) -> Vec<(String, String)> {
server
.received_requests()
.await
.expect("request recording is on")
.iter()
.map(|r| (r.method.to_string(), r.url.path().to_string()))
.collect()
}
fn assert_only_gets(requests: &[(String, String)]) {
assert!(!requests.is_empty(), "the check must read the gateway");
for (verb, url) in requests {
assert_eq!(verb, "GET", "{url}: a check sends GET requests only");
}
}
fn is_hex64(value: &Value) -> bool {
value
.as_str()
.is_some_and(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()))
}
#[tokio::test]
async fn projects_check_reports_import() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V2, true)).await;
let out = run(
&server,
&args_with(&fx, "v1.0.0", &["--allow-first-change", "adopt the repo"]),
);
let data = data_of(&out);
assert_eq!(data["action"], "import");
assert_eq!(data["allow_first_change"], "adopt the repo");
assert!(data["previous_release"].is_null());
assert_eq!(data["would_change"], true);
assert_eq!(data["members"]["changed"], 1);
assert_eq!(data["members"]["same"], 2);
assert_eq!(data["members"]["added"], 0);
assert_eq!(data["members"]["removed"], 0);
assert_eq!(data["check"], true);
assert_eq!(data["outcome"], "checked");
assert_eq!(data["project"], "CapApp");
assert_eq!(data["gateway"], "capgw");
assert_eq!(data["release"], "v1.0.0");
assert_eq!(data["env"], "dev");
assert!(is_hex64(&data["artifact_sha256"]), "{data}");
assert!(is_hex64(&data["live_sha256"]), "{data}");
assert_ne!(data["artifact_sha256"], data["live_sha256"]);
assert_eq!(data["removed"], json!([]));
assert_eq!(data["ok"], true);
assert!(data["slug"].is_null());
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_reports_skip() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let first = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(first["action"], "record");
let sha = first["artifact_sha256"].as_str().expect("sha").to_string();
put_record_sha(&fx, "v1.0.0", &sha);
let out = run(&server, &check_args(&fx, "dev"));
let data = data_of(&out);
assert_eq!(data["action"], "skip");
assert_eq!(data["would_change"], false);
assert_eq!(data["artifact_sha256"], data["live_sha256"]);
assert_eq!(data["recorded_sha256"], sha.as_str());
assert_eq!(data["live_matches_last_deploy"], true);
assert_eq!(data["warnings"], json!([]));
assert_eq!(data["members"]["changed"], 0);
assert_eq!(data["members"]["same"], 3);
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_reports_record() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["action"], "record");
assert_eq!(data["would_change"], true);
assert_eq!(data["artifact_sha256"], data["live_sha256"]);
assert!(data["previous_release"].is_null());
assert!(data["recorded_sha256"].is_null());
assert!(data["live_matches_last_deploy"].is_null());
assert!(data["allow_first_change"].is_null());
assert_only_gets(&methods(&server).await);
put_record(&fx, "v0.9.0");
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["action"], "record");
assert_eq!(data["previous_release"], "v0.9.0");
}
#[tokio::test]
async fn projects_check_first_deploy_change_refuses() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V2, true)).await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "destructive_change_refused");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["first_deploy", "CapApp", "--allow-first-change"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_live_drift_warns() {
let fx = fixture();
put_record(&fx, "v0.9.0");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V2, true)).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["action"], "import");
assert_eq!(data["live_matches_last_deploy"], false);
assert_eq!(data["recorded_sha256"], "a".repeat(64));
let warnings = data["warnings"].as_array().expect("warnings");
assert_eq!(warnings.len(), 1, "{data}");
let text = warnings[0].as_str().expect("text");
assert!(text.contains("CapApp") && text.contains("v0.9.0"), "{text}");
}
#[tokio::test]
async fn projects_check_reports_create() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let out = run(&server, &check_args(&fx, "dev"));
let data = data_of(&out);
assert_eq!(data["action"], "create");
assert_eq!(data["would_change"], true);
assert!(data["live_sha256"].is_null());
assert!(is_hex64(&data["artifact_sha256"]));
assert_eq!(data["members"]["added"], 3);
let requests = methods(&server).await;
assert_only_gets(&requests);
assert!(
requests.iter().all(|(_, url)| !url.contains("/export/")),
"an absent project has nothing to export: {requests:?}"
);
}
async fn mount_tag_providers(server: &MockServer, names: &[&str]) {
let items: Vec<Value> = names
.iter()
.map(|n| json!({"name": n, "enabled": true, "type": "ignition/tag-provider"}))
.collect();
let total = items.len();
Mock::given(method("GET"))
.and(path("/data/api/v1/resources/list/ignition/tag-provider"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": items,
"metadata": {"total": total, "matching": total, "limit": -1, "offset": 0}
})))
.mount(server)
.await;
}
async fn mount_tag_export(server: &MockServer, provider: &str, body: Value) {
Mock::given(method("GET"))
.and(path("/data/api/v1/tags/export"))
.and(query_param("provider", provider))
.and(query_param("type", "json"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
fn add_timer_reading_tags(fx: &Fixture) {
put(
&fx.repo,
"ignition/timer/Poll/resource.json",
br#"{"scope":"G","version":1,"files":["handleTimerEvent.py"],"attributes":{}}"#,
);
put(
&fx.repo,
"ignition/timer/Poll/handleTimerEvent.py",
b"def handleTimerEvent():\n system.tag.readBlocking([\"[default]Area/Present\", \"[default]Area/Absent\"])\n",
);
}
fn add_script_over_the_read_limit(fx: &Fixture) {
add_script_over_the_read_limit_in(fx, "big");
}
fn add_script_over_the_read_limit_in(fx: &Fixture, dir: &str) {
use ignition_core::deploy::project_tags::MEMBER_READ_MAX;
put(
&fx.repo,
&format!("ignition/script-python/{dir}/resource.json"),
br#"{"scope":"A","version":1,"files":["code.py"],"attributes":{}}"#,
);
let filler = MEMBER_READ_MAX as usize - 1;
let mut code = "#".repeat(filler);
code.push('\n');
code.push_str("TAGS = [\"[default]Past/Limit\"]\n");
put(
&fx.repo,
&format!("ignition/script-python/{dir}/code.py"),
code.as_bytes(),
);
}
#[tokio::test]
async fn projects_check_reports_a_missing_tag() {
let fx = fixture();
add_timer_reading_tags(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_tag_providers(&server, &["default"]).await;
mount_tag_export(
&server,
"default",
json!({
"name": "",
"tagType": "Provider",
"tags": [{
"name": "Area",
"tagType": "Folder",
"tags": [{"name": "Present", "tagType": "AtomicTag", "dataType": "Int4"}]
}]
}),
)
.await;
let out = run(&server, &check_args(&fx, "dev"));
let data = data_of(&out);
assert_eq!(data["action"], "create");
assert_eq!(
data["missing_tags"],
json!([{
"path": "[default]Area/Absent",
"files": ["ignition/timer/Poll/handleTimerEvent.py"]
}])
);
assert_eq!(data["missing_tags_count"], 1);
assert!(data["missing_tags_unavailable"].is_null(), "{data}");
assert_eq!(data["missing_tags_unknown_providers"], json!([]));
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_lists_unmanaged() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp", "Other", "Global"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, false)).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["unmanaged"], json!(["Global", "Other"]));
assert_eq!(data["unmanaged_count"], 2);
let requests = methods(&server).await;
assert_only_gets(&requests);
assert!(
requests
.iter()
.all(|(_, url)| !url.contains("Other") && !url.contains("Global")),
"other projects are listed by name and never read: {requests:?}"
);
}
#[tokio::test]
async fn projects_check_reports_removed_members() {
let fx = fixture();
let server = MockServer::start().await;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
for (name, bytes) in [
(
"project.json",
br#"{"title":"Cap App","enabled":true}"#.as_slice(),
),
(
"ignition/script-python/cap/resource.json",
SCRIPT_RESOURCE.as_bytes(),
),
("ignition/script-python/cap/code.py", CODE_V1),
("ignition/script-python/old/code.py", b"gone"),
(
"ignition/script-python/old/resource.json",
br#"{"files":["code.py"]}"#.as_slice(),
),
] {
writer.start_file(name, opts).expect("start");
writer.write_all(bytes).expect("write");
}
let live = writer.finish().expect("finish").into_inner();
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, live).await;
let data = data_of(&run(
&server,
&args_with(&fx, "v1.0.0", &["--allow-first-change", "adopt"]),
));
assert_eq!(data["action"], "import");
assert_eq!(data["members"]["removed"], 2);
assert_eq!(data["removed_count"], 2);
assert_eq!(
data["removed"],
json!([
"ignition/script-python/old/code.py",
"ignition/script-python/old/resource.json"
])
);
}
#[tokio::test]
async fn projects_check_env_not_listed() {
let fx = fixture();
put(
&fx.targets,
"targets/prod.yaml",
std::fs::read(fx.targets.join("targets/dev.yaml"))
.expect("dev target")
.as_slice(),
);
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "prod"));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "a refusal leaves stdout empty");
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr envelope");
assert_eq!(err["error"]["code"], "deploy_tree_invalid");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("deploy/project.yaml"),
"{err}"
);
assert!(
methods(&server).await.is_empty(),
"the refusal comes before any request"
);
}
#[tokio::test]
async fn projects_check_creates_no_ign_dir() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V2, true)).await;
let out = run(
&server,
&args_with(&fx, "v1.0.0", &["--allow-first-change", "adopt"]),
);
data_of(&out);
assert!(
!fx.repo.join(".ign").exists(),
"a check writes no run directory"
);
assert!(!fx.targets.join(".ign").exists());
assert!(!fx.repo.join("deploy/.ign").exists());
}
#[tokio::test]
async fn projects_check_targets_default_to_the_repo() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let bytes = std::fs::read(fx.targets.join("targets/dev.yaml")).expect("target");
put(&fx.repo, "targets/dev.yaml", &bytes);
let mut args = check_args(&fx, "dev");
let at = args.iter().position(|a| a == "--targets").expect("flag");
args.drain(at..=at + 1);
let data = data_of(&run(&server, &args));
assert_eq!(data["action"], "record", "equal content, no record yet");
}
#[tokio::test]
async fn projects_check_missing_project_json_refuses() {
let fx = fixture();
std::fs::remove_file(fx.repo.join("project.json")).expect("rm");
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(out.status.code(), Some(2));
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr envelope");
assert_eq!(err["error"]["code"], "deploy_tree_invalid");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("a project repo holds project.json at its root"),
"{err}"
);
assert!(methods(&server).await.is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn projects_check_symlink_in_the_walk_refuses() {
let fx = fixture();
let outside = tempfile::tempdir().expect("outside");
std::fs::write(outside.path().join("secret.txt"), b"secret").expect("write");
std::os::unix::fs::symlink(
outside.path().join("secret.txt"),
fx.repo.join("ignition/script-python/cap/link.py"),
)
.expect("symlink");
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(out.status.code(), Some(2));
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr envelope");
assert_eq!(err["error"]["code"], "deploy_tree_invalid");
let message = err["error"]["message"].as_str().expect("message");
assert!(
message.contains("ignition/script-python/cap/link.py"),
"{message}"
);
assert!(!message.contains("secret"), "{message}");
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_human_lines() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp", "Other"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V2, true)).await;
let mut args = args_with(&fx, "v1.0.0", &["--allow-first-change", "adopt the repo"]);
args.remove(0);
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(
stdout.contains("project CapApp on capgw (env dev, release v1.0.0)"),
"{stdout}"
);
assert!(stdout.contains("check: import (would change)"), "{stdout}");
assert!(
stdout.contains("release: v1.0.0 (last deployed by this tool: none)"),
"{stdout}"
);
assert!(
stdout.contains("first change allowed: adopt the repo"),
"{stdout}"
);
assert!(stdout.contains("artifact sha256 "), "{stdout}");
assert!(
stdout.contains("members: 0 added, 1 changed, 0 removed, 2 same"),
"{stdout}"
);
assert!(
stdout.contains("other projects on the gateway: 1"),
"{stdout}"
);
}
fn add_noise(repo: &Path) {
put(repo, "tags/t.json", b"{}");
put(repo, "images/i.png", b"png");
put(
repo,
"ignition/script-python/cap/__pycache__/code.cpython-311.pyc",
b"pyc",
);
put(repo, "ignition/script-python/cap/notes.txt", b"unlisted");
put(repo, "ignition/.git/HEAD", b"ref: refs/heads/main");
put(repo, ".ign/deploy/gw/run/export/x.zip", b"a previous run");
put(
repo,
"ignition/script-python/scratch/resource.json",
br#"{"files":["s.py"]}"#,
);
put(repo, "ignition/script-python/scratch/s.py", b"pass");
put(repo, ".ignexclude", b"ignition/script-python/scratch/\n");
}
#[tokio::test]
async fn projects_check_reports_excluded() {
let fx = fixture();
add_noise(&fx.repo);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(
data["excluded"],
json!([
".ign/",
".ignexclude",
"deploy/",
"docs/",
"ignition/.git/",
"ignition/script-python/cap/__pycache__/",
"ignition/script-python/cap/notes.txt",
"ignition/script-python/scratch/",
"images/",
"tags/"
])
);
assert_eq!(
data["excluded_count"],
data["excluded"].as_array().expect("array").len()
);
assert_eq!(data["members"]["added"], 3);
assert_only_gets(&methods(&server).await);
assert!(fx.repo.join(".ign/deploy/gw/run/export/x.zip").exists());
}
#[tokio::test]
async fn projects_check_ignexclude_changes_digest() {
let fx = fixture();
add_noise(&fx.repo);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let excluding = data_of(&run(&server, &check_args(&fx, "dev")));
std::fs::remove_file(fx.repo.join(".ignexclude")).expect("rm");
let including = data_of(&run(&server, &check_args(&fx, "dev")));
assert_ne!(
excluding["artifact_sha256"], including["artifact_sha256"],
"the scratch package is part of the artifact once nothing excludes it"
);
assert_eq!(including["members"]["added"], 5);
let listed: Vec<&str> = including["excluded"]
.as_array()
.expect("array")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(!listed.contains(&"ignition/script-python/scratch/"));
}
#[tokio::test]
async fn projects_check_bad_ignexclude_refuses_before_any_request() {
let fx = fixture();
put(&fx.repo, ".ignexclude", b"docs/\n**/x\n");
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(out.status.code(), Some(2));
let err: Value = serde_json::from_slice(&out.stderr).expect("stderr envelope");
assert_eq!(err["error"]["code"], "deploy_tree_invalid");
let message = err["error"]["message"].as_str().expect("message");
assert!(message.contains(".ignexclude:2:1"), "{message}");
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_human_prints_the_excluded_count() {
let fx = fixture();
add_noise(&fx.repo);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let mut args = check_args(&fx, "dev");
args.remove(0);
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(
stdout.contains("excluded: 10 path(s) left out of the artifact"),
"{stdout}"
);
assert!(
stdout.contains(" ignition/script-python/scratch/"),
"{stdout}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn projects_check_human_escapes_control_characters_in_excluded_names() {
let fx = fixture();
put(&fx.repo, "tags\u{1b}[2J/t.json", b"{}");
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let mut human = check_args(&fx, "dev");
human.remove(0);
let out = run(&server, &human);
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(stdout.contains(" tags\\u{1b}[2J/"), "{stdout:?}");
assert!(!stdout.contains('\u{1b}'), "no raw ESC in {stdout:?}");
let data = data_of(&run(&server, &check_args(&fx, "dev")));
let listed: Vec<&str> = data["excluded"]
.as_array()
.expect("array")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(listed.contains(&"tags\u{1b}[2J/"), "{listed:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn projects_check_human_error_escapes_control_characters_in_a_path() {
let fx = fixture();
let outside = tempfile::tempdir().expect("outside");
std::fs::write(outside.path().join("secret.txt"), b"secret").expect("write");
std::os::unix::fs::symlink(
outside.path().join("secret.txt"),
fx.repo.join("ignition/script-python/cap/l\u{1b}[2J.py"),
)
.expect("symlink");
let server = MockServer::start().await;
let mut args = check_args(&fx, "dev");
args.remove(0);
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(stderr.contains("l\\u{1b}[2J.py"), "{stderr:?}");
assert!(!stderr.contains('\u{1b}'), "no raw ESC in {stderr:?}");
assert!(methods(&server).await.is_empty());
}
fn error_of(out: &std::process::Output) -> Value {
assert!(out.stdout.is_empty(), "a refusal leaves stdout empty");
serde_json::from_slice(&out.stderr).expect("stderr envelope")
}
#[tokio::test]
async fn projects_check_git_dir_refuses() {
let fx = fixture();
std::fs::create_dir_all(fx.data.join("projects/CapApp/.git")).expect("git dir");
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "project_git_managed");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["CapApp", "projects/CapApp/.git", "directory"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(
methods(&server).await.is_empty(),
"the refusal comes before any gateway request"
);
}
#[tokio::test]
async fn projects_check_git_file_and_symlink_refuse_with_their_kind() {
for (kind, make) in [("file", false), ("symlink", true)] {
let fx = fixture();
let project = fx.data.join("projects/CapApp");
std::fs::create_dir_all(&project).expect("project dir");
if make {
#[cfg(unix)]
std::os::unix::fs::symlink("/nonexistent", project.join(".git")).expect("symlink");
#[cfg(not(unix))]
continue;
} else {
std::fs::write(project.join(".git"), b"gitdir: ../x").expect("file");
}
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(out.status.code(), Some(6), "{kind}");
let err = error_of(&out);
assert_eq!(err["error"]["code"], "project_git_managed", "{kind}");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains(&format!("({kind})")),
"{err}"
);
assert!(methods(&server).await.is_empty(), "{kind}");
}
}
#[tokio::test]
async fn projects_check_without_git_proceeds() {
let fx = fixture();
std::fs::create_dir_all(fx.data.join("projects/CapApp")).expect("project dir");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["action"], "record", "equal content, no record yet");
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_missing_projects_dir_is_transport_failed() {
let fx = fixture();
std::fs::remove_dir(fx.data.join("projects")).expect("rmdir");
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(out.status.code(), Some(4));
let err = error_of(&out);
assert_eq!(err["error"]["code"], "transport_failed");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("nodes.master"),
"{err}"
);
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_no_host_access_refuses() {
let fx = fixture();
put(
&fx.targets,
"targets/dev.yaml",
b"schema_version: 1\ngateway: capgw\nmode: dev\nnodes:\n master:\n profile: dev\n",
);
let server = MockServer::start().await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "deploy_tree_invalid");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("set ssh or local; this verb needs host access"),
"{err}"
);
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_human_git_refusal_names_the_entry() {
let fx = fixture();
std::fs::create_dir_all(fx.data.join("projects/CapApp/.git")).expect("git dir");
let server = MockServer::start().await;
let mut args = check_args(&fx, "dev");
args.remove(0);
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(6));
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(stderr.contains("projects/CapApp/.git"), "{stderr}");
assert!(stderr.contains("administrator"), "{stderr}");
assert!(stderr.contains("hint:"), "{stderr}");
assert!(methods(&server).await.is_empty());
}
fn put_record(fx: &Fixture, release: &str) {
put_record_sha(fx, release, &"a".repeat(64));
}
fn put_record_sha(fx: &Fixture, release: &str, artifact_sha256: &str) {
use ignition_core::deploy::projects::ReleaseRecord;
let record = ReleaseRecord {
schema_version: 1,
gateway: "capgw".to_string(),
project: "CapApp".to_string(),
release: release.to_string(),
artifact_sha256: artifact_sha256.to_string(),
run_id: "20261020T101500Z-ab12".to_string(),
deployed_at: "2026-10-20T10:15:00Z".to_string(),
};
put(
&fx.data,
"ign-deploy/capgw/projects/CapApp/release.json",
&record.to_bytes(),
);
}
fn args_with(fx: &Fixture, release: &str, extra: &[&str]) -> Vec<String> {
let mut args = check_args(fx, "dev");
let at = args.iter().position(|a| a == "--release").expect("flag") + 1;
args[at] = release.to_string();
args.extend(extra.iter().map(|s| (*s).to_string()));
args
}
#[tokio::test]
async fn projects_check_downgrade_refuses() {
let fx = fixture();
put_record(&fx, "v1.2.0");
let server = MockServer::start().await;
let out = run(&server, &args_with(&fx, "v1.1.0", &[]));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "destructive_change_refused");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["downgrade", "CapApp", "--allow-downgrade"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(
methods(&server).await.is_empty(),
"the refusal comes before any gateway request"
);
}
#[tokio::test]
async fn projects_check_downgrade_allowed() {
let fx = fixture();
put_record(&fx, "v1.2.0");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let data = data_of(&run(
&server,
&args_with(&fx, "v1.1.0", &["--allow-downgrade"]),
));
assert_eq!(data["allow_downgrade"], true);
assert_eq!(data["previous_release"], "v1.2.0");
assert_eq!(data["release"], "v1.1.0");
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_equal_release_allowed() {
let fx = fixture();
put_record(&fx, "v1.2.0");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let data = data_of(&run(&server, &args_with(&fx, "v1.2.0", &[])));
assert_eq!(data["allow_downgrade"], false);
assert_eq!(data["previous_release"], "v1.2.0");
}
#[tokio::test]
async fn projects_check_newer_release_reports_the_previous_one() {
let fx = fixture();
put_record(&fx, "v1.2.0-rc.1");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let data = data_of(&run(&server, &args_with(&fx, "v1.2.0", &[])));
assert_eq!(data["previous_release"], "v1.2.0-rc.1");
}
#[tokio::test]
async fn projects_check_bad_release_refuses() {
for bad in ["banana", "1.0", "vv1.0.0", ""] {
let fx = fixture();
std::fs::remove_dir(fx.data.join("projects")).expect("rmdir");
let server = MockServer::start().await;
let out = run(&server, &args_with(&fx, bad, &[]));
assert_eq!(out.status.code(), Some(2), "{bad:?}");
let err = error_of(&out);
assert_eq!(err["error"]["code"], "invalid_input", "{bad:?}");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("--release"),
"{err}"
);
assert!(methods(&server).await.is_empty(), "{bad:?}");
}
}
#[tokio::test]
async fn projects_check_corrupt_record_refuses() {
let fx = fixture();
put(
&fx.data,
"ign-deploy/capgw/projects/CapApp/release.json",
b"{\"schema_version\":1}",
);
let server = MockServer::start().await;
let out = run(&server, &args_with(&fx, "v1.1.0", &["--allow-downgrade"]));
assert_eq!(out.status.code(), Some(6));
let err = error_of(&out);
assert_eq!(err["error"]["code"], "deploy_run_invalid");
assert!(
err["error"]["message"]
.as_str()
.expect("message")
.contains("ign-deploy/capgw/projects/CapApp/release.json"),
"{err}"
);
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_overlong_first_change_reason_refuses() {
let fx = fixture();
let server = MockServer::start().await;
let long = "x".repeat(501);
for reason in [long.as_str(), ""] {
let out = run(
&server,
&args_with(&fx, "v1.0.0", &["--allow-first-change", reason]),
);
assert_eq!(out.status.code(), Some(2), "{} chars", reason.len());
assert_eq!(error_of(&out)["error"]["code"], "invalid_input");
}
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_check_human_record_and_drift_lines() {
let fx = fixture();
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let mut args = check_args(&fx, "dev");
args.remove(0);
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(
stdout.contains("check: record the release only (no project change)"),
"{stdout}"
);
assert!(
stdout.contains("release: v1.0.0 (last deployed by this tool: none)"),
"{stdout}"
);
put_record(&fx, "v0.9.0");
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(
stdout.contains("release: v1.0.0 (last deployed by this tool: v0.9.0)"),
"{stdout}"
);
assert!(
stdout.contains(
"warning: project CapApp: the live project no longer matches the last release this tool deployed (v0.9.0)"
),
"{stdout}"
);
}
fn set_repo_parent(fx: &Fixture, parent: &str) {
put(
&fx.repo,
"project.json",
format!(r#"{{"title":"Cap App","enabled":true,"parent":"{parent}"}}"#).as_bytes(),
);
}
async fn mount_list_records(server: &MockServer, items: Vec<Value>) {
let total = items.len();
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": items,
"metadata": {"total": total, "matching": total, "limit": -1, "offset": 0}
})))
.mount(server)
.await;
}
#[tokio::test]
async fn projects_check_parent_missing_refuses() {
let fx = fixture();
set_repo_parent(&fx, "ParentProject");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let out = run(&server, &check_args(&fx, "dev"));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "project_parent_invalid");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["CapApp", "ParentProject", "missing"] {
assert!(message.contains(needle), "{needle}: {message}");
}
let requests = methods(&server).await;
assert!(
requests.iter().all(|(_, url)| !url.contains("/export/")),
"no project export before the refusal: {requests:?}"
);
assert_only_gets(&requests);
}
#[tokio::test]
async fn projects_check_parent_present_passes() {
let fx = fixture();
set_repo_parent(&fx, "ParentProject");
let server = MockServer::start().await;
mount_list_records(
&server,
vec![
json!({"name": "ParentProject", "enabled": true, "inheritable": true, "invalidParent": false}),
json!({"name": "CapApp", "enabled": true}),
],
)
.await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CODE_V1, true)).await;
let out = run(
&server,
&args_with(&fx, "v1.0.0", &["--allow-first-change", "adopt the repo"]),
);
let data = data_of(&out);
assert_eq!(data["parent"], "ParentProject");
assert_eq!(data["action"], "import");
assert_only_gets(&methods(&server).await);
}
fn artifact_hash(fx: &Fixture) -> String {
ignition_core::deploy::project_artifact::build_artifact(&fx.repo)
.expect("the fixture builds")
.digest
.sha256
}
fn git_ignore_runs(fx: &Fixture) {
let status = std::process::Command::new("git")
.args(["init", "-q"])
.arg(&fx.repo)
.status()
.expect("git runs");
assert!(status.success());
put(&fx.repo, ".gitignore", b".ign/\n");
}
fn apply_args(fx: &Fixture, release: &str, hash: Option<&str>, yes: bool) -> Vec<String> {
let mut args = args_with(fx, release, &[]);
args.retain(|a| a != "--check");
if let Some(hash) = hash {
args.push("--artifact-sha256".into());
args.push(hash.into());
}
if yes {
args.push("--yes".into());
}
args
}
struct ExportAfterImport {
imported: Arc<AtomicBool>,
before: Vec<u8>,
after: Vec<u8>,
}
impl Respond for ExportAfterImport {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let zip = if self.imported.load(Ordering::SeqCst) {
self.after.clone()
} else {
self.before.clone()
};
ResponseTemplate::new(200)
.set_body_raw(zip, "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"CapApp-export.zip\"",
)
}
}
struct ImportAnswer {
imported: Arc<AtomicBool>,
}
impl Respond for ImportAnswer {
fn respond(&self, _request: &Request) -> ResponseTemplate {
self.imported.store(true, Ordering::SeqCst);
ResponseTemplate::new(200)
.set_body_json(json!({"success": true, "changes": [{"name": "CapApp"}]}))
}
}
fn designers_body(sessions: &[(&str, &str, &str)]) -> Value {
let items: Vec<Value> = sessions
.iter()
.enumerate()
.map(|(i, (user, project, address))| {
json!({
"id": format!("d-{i}"),
"user": user,
"project": project,
"address": address,
"uptime": 1,
"lastcomm": 1,
"timeout": 1,
"memory": {},
})
})
.collect();
json!({
"items": items,
"metadata": {"total": items.len(), "matching": items.len(), "limit": -1, "offset": 0}
})
}
async fn mount_gateway_name(server: &MockServer, name: &str, priority: u8) {
Mock::given(method("GET"))
.and(path("/data/api/v1/gateway-info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": name,
"deploymentMode": "dev",
"ignitionVersion": "8.3.6 (b2026042713)"
})))
.with_priority(priority)
.mount(server)
.await;
}
async fn mount_designers(server: &MockServer, sessions: &[(&str, &str, &str)]) {
Mock::given(method("GET"))
.and(path("/data/api/v1/designers"))
.respond_with(ResponseTemplate::new(200).set_body_json(designers_body(sessions)))
.mount(server)
.await;
}
async fn mount_deployable(server: &MockServer, before: Option<Vec<u8>>, after: Vec<u8>) {
let imported = Arc::new(AtomicBool::new(false));
let overwrite = before.is_some();
mount_gateway_name(server, "capgw", 10).await;
Mock::given(method("GET"))
.and(path("/data/api/v1/designers"))
.respond_with(ResponseTemplate::new(200).set_body_json(designers_body(&[])))
.with_priority(10)
.mount(server)
.await;
match &before {
Some(_) => {
mount_list(server, &["CapApp"]).await;
mount_find_found(server).await;
}
None => {
mount_list(server, &[]).await;
mount_find_missing(server).await;
}
}
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/export/CapApp"))
.respond_with(ExportAfterImport {
imported: imported.clone(),
before: before.unwrap_or_else(|| after.clone()),
after,
})
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/data/api/v1/projects/import/CapApp"))
.and(query_param(
"overwrite",
if overwrite { "true" } else { "false" },
))
.respond_with(ImportAnswer { imported })
.mount(server)
.await;
}
fn run_dirs(fx: &Fixture) -> Vec<PathBuf> {
let root = fx.repo.join(".ign/deploy/capgw");
match std::fs::read_dir(&root) {
Ok(entries) => entries.map(|e| e.expect("entry").path()).collect(),
Err(_) => Vec::new(),
}
}
fn position(requests: &[(String, String)], verb: &str, needle: &str) -> Vec<usize> {
requests
.iter()
.enumerate()
.filter(|(_, (v, p))| v == verb && p.contains(needle))
.map(|(i, _)| i)
.collect()
}
#[tokio::test]
async fn projects_apply_imports_and_records() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
let old = export_zip(CODE_V2, true);
mount_deployable(&server, Some(old.clone()), export_zip(CODE_V1, true)).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
let data = data_of(&out);
assert_eq!(data["outcome"], "imported");
assert_eq!(data["ok"], true);
assert_eq!(data["check"], false);
assert_eq!(data["record_written"], true);
assert_eq!(data["artifact_sha256"], hash.as_str());
let run_id = data["run_id"].as_str().expect("run id").to_string();
assert_eq!(data["run_dir"], format!(".ign/deploy/capgw/{run_id}"));
let dir = fx.repo.join(".ign/deploy/capgw").join(&run_id);
for file in ["run.json", "plan.json", "events.ndjson", "result.json"] {
assert!(dir.join(file).is_file(), "{file}");
}
let saved = dir.join("export/CapApp.zip");
assert_eq!(std::fs::read(&saved).expect("pre-image"), old);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &Path| std::fs::metadata(p).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode(&saved), 0o600);
assert_eq!(mode(&dir.join("export")), 0o700);
assert_eq!(mode(&dir), 0o700);
}
let run_json: Value =
serde_json::from_slice(&std::fs::read(dir.join("run.json")).expect("run.json"))
.expect("run.json is json");
assert_eq!(run_json["release"], "v1.1.0");
assert_eq!(run_json["allow_downgrade"], false);
assert_eq!(run_json["artifact_sha256"], hash.as_str());
use ignition_core::deploy::projects::ReleaseRecord;
let bytes = std::fs::read(
fx.data
.join("ign-deploy/capgw/projects/CapApp/release.json"),
)
.expect("record");
let record = ReleaseRecord::parse(&bytes, "release.json", "capgw", "CapApp").expect("parses");
assert_eq!(record.release, "v1.1.0");
assert_eq!(record.artifact_sha256, hash);
assert_eq!(record.run_id, run_id);
let requests = methods(&server).await;
let exports = position(&requests, "GET", "/export/");
let imports = position(&requests, "POST", "/import/");
assert_eq!(imports.len(), 1, "{requests:?}");
assert!(
exports.iter().filter(|i| **i < imports[0]).count() >= 2,
"plan and pre-image exports precede the import: {requests:?}"
);
assert!(
exports.iter().any(|i| *i > imports[0]),
"the project is read back: {requests:?}"
);
assert_eq!(requests.iter().filter(|(v, _)| v == "POST").count(), 1);
}
#[tokio::test]
async fn projects_apply_creates_absent() {
let fx = fixture();
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(&server, None, export_zip(CODE_V1, true)).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), true));
let data = data_of(&out);
assert_eq!(data["outcome"], "created");
assert_eq!(data["ok"], true);
assert_eq!(data["record_written"], true);
let dirs = run_dirs(&fx);
assert_eq!(dirs.len(), 1);
assert!(
!dirs[0].join("export").exists(),
"an absent project has no pre-image"
);
assert!(
fx.data
.join("ign-deploy/capgw/projects/CapApp/release.json")
.is_file()
);
let requests = methods(&server).await;
let imports = position(&requests, "POST", "/import/");
assert_eq!(imports.len(), 1, "{requests:?}");
assert!(
position(&requests, "GET", "/export/")
.iter()
.all(|i| *i > imports[0]),
"no export before the create: {requests:?}"
);
}
#[tokio::test]
async fn projects_apply_hash_mismatch_uploads_nothing() {
let fx = fixture();
git_ignore_runs(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
let zeros = "0".repeat(64);
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&zeros), true));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "project_hash_mismatch");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["CapApp", zeros.as_str(), artifact_hash(&fx).as_str()] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(
methods(&server).await.is_empty(),
"nothing is requested before the hash passes"
);
assert!(run_dirs(&fx).is_empty());
assert!(!fx.repo.join(".ign").exists());
}
#[tokio::test]
async fn projects_apply_needs_hash_noninteractive() {
let fx = fixture();
git_ignore_runs(&fx);
let server = MockServer::start().await;
let out = run(&server, &apply_args(&fx, "v1.0.0", None, true));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "invalid_input");
let message = err["error"]["message"].as_str().expect("message");
assert!(message.contains("--artifact-sha256"), "{message}");
assert!(
!err["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("--file"),
"a flag refusal does not point at --file"
);
assert!(methods(&server).await.is_empty());
assert!(!fx.repo.join(".ign").exists());
}
#[tokio::test]
async fn projects_apply_bad_hash_format() {
let fx = fixture();
git_ignore_runs(&fx);
let server = MockServer::start().await;
let upper = artifact_hash(&fx).to_uppercase();
for bad in [upper.as_str(), "abc", &"g".repeat(64)] {
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(bad), true));
assert_eq!(
out.status.code(),
Some(2),
"{bad}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(error_of(&out)["error"]["code"], "invalid_input", "{bad}");
}
assert!(methods(&server).await.is_empty());
}
#[tokio::test]
async fn projects_apply_needs_yes() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), false));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "confirmation_required");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["CapApp", "import", hash.as_str(), "capgw", "v1.1.0"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert_only_gets(&methods(&server).await);
assert!(run_dirs(&fx).is_empty());
assert!(!fx.repo.join(".ign").exists());
}
#[tokio::test]
async fn projects_apply_skip_writes_nothing() {
let fx = fixture();
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
put_record_sha(&fx, "v1.1.0", &hash);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V1, true)),
export_zip(CODE_V1, true),
)
.await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
let data = data_of(&out);
assert_eq!(data["outcome"], "skipped");
assert_eq!(data["ok"], true);
assert!(data["run_id"].is_null());
assert_eq!(data["record_written"], false);
assert!(!fx.repo.join(".ign").exists());
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_apply_record_only() {
let fx = fixture();
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V1, true)),
export_zip(CODE_V1, true),
)
.await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
let data = data_of(&out);
assert_eq!(data["outcome"], "recorded");
assert_eq!(data["ok"], true);
assert_eq!(data["record_written"], true);
assert!(data["run_id"].is_string());
assert!(
fx.data
.join("ign-deploy/capgw/projects/CapApp/release.json")
.is_file()
);
assert_only_gets(&methods(&server).await);
let dirs = run_dirs(&fx);
assert_eq!(dirs.len(), 1);
assert!(
!dirs[0].join("export").exists(),
"the project is not replaced, so nothing is saved"
);
}
async fn mount_unverifiable(server: &MockServer) {
mount_deployable(
server,
Some(export_zip(CODE_V2, true)),
export_zip(b"def f():\n return 3\n", true),
)
.await;
}
fn record_path(fx: &Fixture) -> PathBuf {
fx.data
.join("ign-deploy/capgw/projects/CapApp/release.json")
}
#[tokio::test]
async fn projects_apply_unverified_exit_6() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let record_before = std::fs::read(record_path(&fx)).expect("record");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_unverifiable(&server).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stderr.is_empty(),
"under --json stderr is empty: {}",
String::from_utf8_lossy(&out.stderr)
);
let envelope: Value = serde_json::from_slice(&out.stdout).expect("one envelope");
assert_eq!(
envelope["ok"], true,
"a success envelope, then the exit code"
);
let data = &envelope["data"];
assert_eq!(data["outcome"], "imported_unverified");
assert_eq!(data["ok"], false);
assert_eq!(data["slug"], "deploy_project_unverified");
assert_eq!(data["record_written"], false);
assert_eq!(data["artifact_sha256"], hash.as_str());
let run_id = data["run_id"].as_str().expect("run id");
let run_dir = format!(".ign/deploy/capgw/{run_id}");
assert_eq!(data["run_dir"], run_dir.as_str());
assert_eq!(
data["restore_command"],
format!(
"ign project import CapApp --file {run_dir}/export/CapApp.zip --collision-policy overwrite --yes"
)
.as_str()
);
let saved = fx.repo.join(&run_dir).join("export/CapApp.zip");
assert_eq!(
std::fs::read(&saved).expect("pre-image"),
export_zip(CODE_V2, true)
);
let result: Value = serde_json::from_slice(
&std::fs::read(fx.repo.join(&run_dir).join("result.json")).expect("result.json"),
)
.expect("json");
assert_eq!(result["outcome"], "imported_unverified");
assert_eq!(result["ok"], false);
assert_eq!(result["slug"], "deploy_project_unverified");
assert_eq!(result["restore_command"], data["restore_command"]);
let requests = methods(&server).await;
assert_eq!(
position(&requests, "POST", "/import/").len(),
1,
"no second, unreviewed import: {requests:?}"
);
assert_eq!(requests.iter().filter(|(v, _)| v == "POST").count(), 1);
assert_eq!(
std::fs::read(record_path(&fx)).expect("record"),
record_before,
"no release record is written for an unverified import"
);
}
#[tokio::test]
async fn projects_apply_unverified_create_has_no_restore_command() {
let fx = fixture();
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(&server, None, export_zip(b"def f():\n return 3\n", true)).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), true));
assert_eq!(out.status.code(), Some(6));
let envelope: Value = serde_json::from_slice(&out.stdout).expect("envelope");
let data = &envelope["data"];
assert_eq!(data["outcome"], "imported_unverified");
assert_eq!(data["ok"], false);
assert_eq!(data["slug"], "deploy_project_unverified");
assert!(data["restore_command"].is_null(), "{data}");
assert!(
!record_path(&fx).exists(),
"no record for an unverified create"
);
}
#[tokio::test]
async fn projects_apply_human_output() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_unverifiable(&server).await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
args.remove(0);
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(6));
let stdout = String::from_utf8(out.stdout).expect("utf-8");
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(
stdout.contains("project CapApp on capgw (env dev, release v1.1.0)"),
"{stdout}"
);
assert!(
stdout.contains("outcome: imported_unverified (NOT verified)"),
"{stdout}"
);
assert!(
stdout.contains("verdict: deploy_project_unverified"),
"{stdout}"
);
assert!(
stdout.contains(&format!("artifact sha256 {hash}")),
"{stdout}"
);
assert!(stdout.contains("live sha256 "), "{stdout}");
assert!(
stdout.contains("run directory .ign/deploy/capgw/"),
"{stdout}"
);
assert!(
stdout.contains("ign project import CapApp --file .ign/deploy/capgw/"),
"{stdout}"
);
assert!(
stdout.contains("/export/CapApp.zip --collision-policy overwrite --yes"),
"{stdout}"
);
assert!(stdout.contains("release record: not written"), "{stdout}");
let lines: Vec<&str> = stderr.lines().collect();
for needle in ["pre-image saved", "import sent", "verify NOT green"] {
assert!(
lines
.iter()
.any(|l| l.starts_with("ign deploy projects: ") && l.contains(needle)),
"{needle}: {stderr}"
);
}
assert!(!stderr.contains("return 3"), "{stderr}");
assert!(!stderr.contains("return 2"), "{stderr}");
}
#[tokio::test]
async fn projects_apply_verified_human_has_no_verdict_lines() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
args.remove(0);
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(stdout.contains("outcome: imported\n"), "{stdout}");
assert!(!stdout.contains("verdict:"), "{stdout}");
assert!(!stdout.contains("restore"), "{stdout}");
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(
stderr.contains("ign deploy projects: verify green"),
"{stderr}"
);
}
#[tokio::test]
async fn projects_apply_designer_open_refuses() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
mount_designers(&server, &[("alice", "CapApp", "192.0.2.5:52526")]).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "deploy_preflight_failed");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["designers", "alice on CapApp from 192.0.2.5:52526"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(run_dirs(&fx).is_empty(), "nothing is written");
assert!(!fx.repo.join(".ign").exists());
let requests = methods(&server).await;
assert!(
position(&requests, "POST", "/import/").is_empty(),
"nothing is imported: {requests:?}"
);
assert_eq!(requests.iter().filter(|(v, _)| v == "POST").count(), 0);
let record = std::fs::read_to_string(record_path(&fx)).expect("record");
assert!(
record.contains("v1.0.0"),
"the record is untouched: {record}"
);
}
#[tokio::test]
async fn projects_apply_designers_proceed_records() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
mount_designers(&server, &[("alice", "CapApp", "192.0.2.5:52526")]).await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
args.extend(["--designers".to_string(), "proceed".to_string()]);
let out = run(&server, &args);
let data = data_of(&out);
assert_eq!(data["outcome"], "imported");
let run_id = data["run_id"].as_str().expect("run id");
let dir = fx.repo.join(".ign/deploy/capgw").join(run_id);
let run_json: Value =
serde_json::from_slice(&std::fs::read(dir.join("run.json")).expect("run.json"))
.expect("run.json is json");
assert_eq!(run_json["designers"], "proceed");
assert_eq!(
run_json["designers_seen"],
json!([{"user": "alice", "project": "CapApp", "host": "192.0.2.5:52526"}])
);
let events = std::fs::read_to_string(dir.join("events.ndjson")).expect("events");
let proceeded: Vec<Value> = events
.lines()
.map(|l| serde_json::from_str::<Value>(l).expect("line"))
.filter(|l| l["event"] == "designers_proceed")
.collect();
assert_eq!(proceeded.len(), 1, "{events}");
assert_eq!(
proceeded[0]["sessions"],
json!([{"user": "alice", "project": "CapApp", "host": "192.0.2.5:52526"}])
);
}
#[tokio::test]
async fn projects_apply_wrong_target_refuses() {
let fx = fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
mount_gateway_name(&server, "somewhere-else", 1).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_eq!(
out.status.code(),
Some(6),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "deploy_preflight_failed");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["wrong_target", "host's systemName"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(
!message.contains("somewhere-else"),
"a name the gateway reports is not echoed: {message}"
);
assert!(run_dirs(&fx).is_empty(), "nothing is written");
assert!(!fx.repo.join(".ign").exists());
let requests = methods(&server).await;
assert!(
position(&requests, "POST", "/import/").is_empty(),
"nothing is imported: {requests:?}"
);
assert_eq!(
position(&requests, "GET", "/designers").len(),
0,
"the Designer list is read after the target is proven: {requests:?}"
);
}
async fn mount_family(server: &MockServer, family: &[(&str, &str)]) {
let mut items = vec![json!({"name": "CapApp", "enabled": true})];
items.extend(
family
.iter()
.map(|(name, parent)| json!({"name": name, "enabled": true, "parent": parent})),
);
let total = items.len();
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": items,
"metadata": {"total": total, "matching": total, "limit": -1, "offset": 0}
})))
.with_priority(1)
.mount(server)
.await;
}
async fn mount_child_export(server: &MockServer, name: &str, zip: Vec<u8>) {
Mock::given(method("GET"))
.and(path(format!("/data/api/v1/projects/export/{name}")))
.respond_with(ResponseTemplate::new(200).set_body_raw(zip, "application/zip"))
.mount(server)
.await;
}
fn put_child_record(fx: &Fixture, child: &str, release: &str, artifact_sha256: &str) {
use ignition_core::deploy::projects::ReleaseRecord;
let record = ReleaseRecord {
schema_version: 1,
gateway: "capgw".to_string(),
project: child.to_string(),
release: release.to_string(),
artifact_sha256: artifact_sha256.to_string(),
run_id: "20261020T101500Z-cd34".to_string(),
deployed_at: "2026-10-20T10:15:00Z".to_string(),
};
put(
&fx.data,
&format!("ign-deploy/capgw/projects/{child}/release.json"),
&record.to_bytes(),
);
}
fn export_sha(zip: &[u8]) -> String {
ignition_core::deploy::project_artifact::canonical_digest(zip)
.expect("a project export")
.sha256
}
async fn parent_deploy() -> (Fixture, MockServer, String, String) {
let fx = fixture();
git_ignore_runs(&fx);
put_record_sha(&fx, "v1.0.0", &export_sha(&export_zip(CODE_V2, true)));
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CODE_V2, true)),
export_zip(CODE_V1, true),
)
.await;
mount_family(
&server,
&[
("ChildA", "CapApp"),
("ChildB", "CapApp"),
("GrandChild", "ChildA"),
("Unrelated", "Elsewhere"),
],
)
.await;
let a = export_zip(CODE_V1, true);
let a_sha = export_sha(&a);
mount_child_export(&server, "ChildA", a).await;
mount_child_export(&server, "ChildB", export_zip(CODE_V2, true)).await;
put_child_record(&fx, "ChildA", "v0.9.0", &a_sha);
put_child_record(&fx, "ChildB", "v0.8.0", &"b".repeat(64));
(fx, server, hash, a_sha)
}
#[tokio::test]
async fn projects_apply_children_ok_and_mismatch() {
let (fx, server, hash, a_sha) = parent_deploy().await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
let data = data_of(&out);
assert_eq!(data["outcome"], "imported");
assert_eq!(data["ok"], true);
let children = data["children"].as_array().expect("children is a list");
assert_eq!(children.len(), 2, "direct children only: {children:?}");
assert_eq!(children[0]["name"], "ChildA");
assert_eq!(children[0]["status"], "ok");
assert_eq!(children[0]["release"], "v0.9.0");
assert_eq!(children[0]["recorded_sha256"], a_sha.as_str());
assert_eq!(children[0]["live_sha256"], a_sha.as_str());
assert_eq!(children[1]["name"], "ChildB");
assert_eq!(children[1]["status"], "mismatch");
assert_eq!(children[1]["release"], "v0.8.0");
assert_eq!(children[1]["recorded_sha256"], "b".repeat(64).as_str());
assert!(is_hex64(&children[1]["live_sha256"]), "{children:?}");
assert_ne!(children[1]["live_sha256"], children[1]["recorded_sha256"]);
let warnings: Vec<&str> = data["warnings"]
.as_array()
.expect("warnings")
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("child project ChildB"), "{warnings:?}");
assert!(warnings[0].contains("v0.8.0"), "{warnings:?}");
let requests = methods(&server).await;
let writes: Vec<_> = requests.iter().filter(|(v, _)| v != "GET").collect();
assert_eq!(writes.len(), 1, "{requests:?}");
assert_eq!(writes[0].1, "/data/api/v1/projects/import/CapApp");
for (verb, url) in &requests {
if url.contains("Child") || url.contains("Unrelated") {
assert_eq!(verb, "GET", "{url}");
assert!(url.contains("/projects/export/Child"), "{url}");
}
}
assert!(
!requests.iter().any(|(_, url)| url.contains("GrandChild")),
"a grandchild is not swept: {requests:?}"
);
}
#[tokio::test]
async fn projects_apply_children_human() {
let (fx, server, hash, _) = parent_deploy().await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
args.remove(0);
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(stdout.contains("\nchildren:\n"), "{stdout}");
assert!(stdout.contains("\n ChildA: ok (v0.9.0)\n"), "{stdout}");
assert!(
stdout.contains("\n ChildB: MISMATCH (v0.8.0)\n"),
"{stdout}"
);
assert!(stdout.contains("warning: child project ChildB"), "{stdout}");
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(
stderr.contains("ign deploy projects: child ChildB: MISMATCH"),
"{stderr}"
);
}
use ignition_core::actions::deploy_projects::PROJECTS_CANARY_EXEMPT;
const CANARY_PROJECT: &str = "CANARY-PROJECT-25";
const CANARY_CHECKOUT: &[u8] = b"PASSWORD = 'CANARY-PROJECT-25'\n";
const CANARY_LIVE: &[u8] = b"PASSWORD = 'CANARY-PROJECT-25'\nOLD = 1\n";
const CANARY_BAD: &[u8] = b"PASSWORD = 'CANARY-PROJECT-25'\nOTHER = 3\n";
fn canary_fixture() -> Fixture {
let fx = fixture();
put(
&fx.repo,
"ignition/script-python/cap/code.py",
CANARY_CHECKOUT,
);
fx
}
fn is_canary_exempt(rel: &Path) -> bool {
let first = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.unwrap_or_default();
PROJECTS_CANARY_EXEMPT.contains(&first.as_str())
}
fn files_below(dir: &Path) -> Vec<PathBuf> {
fn walk(root: &Path, dir: &Path, found: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read_dir") {
let path = entry.expect("entry").path();
if path.is_dir() {
walk(root, &path, found);
} else {
found.push(path.strip_prefix(root).expect("below root").to_path_buf());
}
}
}
let mut found = Vec::new();
walk(dir, dir, &mut found);
found.sort();
found
}
fn holds_canary(bytes: &[u8]) -> bool {
bytes
.windows(CANARY_PROJECT.len())
.any(|w| w == CANARY_PROJECT.as_bytes())
}
fn zip_holds_canary(bytes: &[u8]) -> bool {
use std::io::Read as _;
let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else {
return false;
};
(0..archive.len()).any(|i| {
let mut member = Vec::new();
archive
.by_index(i)
.expect("member")
.read_to_end(&mut member)
.expect("read member");
holds_canary(&member)
})
}
fn canary_files(dir: &Path, honour_exempt: bool) -> Vec<PathBuf> {
files_below(dir)
.into_iter()
.filter(|rel| !(honour_exempt && is_canary_exempt(rel)))
.filter(|rel| {
let bytes = std::fs::read(dir.join(rel)).expect("read file");
holds_canary(&bytes) || zip_holds_canary(&bytes)
})
.collect()
}
fn assert_streams_canary_free(out: &std::process::Output, what: &str) {
assert!(
!String::from_utf8_lossy(&out.stdout).contains(CANARY_PROJECT),
"canary on stdout: {what}"
);
assert!(
!String::from_utf8_lossy(&out.stderr).contains(CANARY_PROJECT),
"canary on stderr: {what}"
);
}
fn assert_run_files_canary_free(fx: &Fixture, what: &str) -> Vec<PathBuf> {
let dirs = run_dirs(fx);
for dir in &dirs {
let walked = files_below(dir);
for file in ["run.json", "plan.json", "events.ndjson", "result.json"] {
assert!(
walked.iter().any(|rel| rel == Path::new(file)),
"{file} was scanned in {dir:?}: {what}"
);
}
let leaks = canary_files(dir, true);
assert!(leaks.is_empty(), "canary in {leaks:?} of {dir:?}: {what}");
}
let records = fx.data.join("ign-deploy");
if records.exists() {
let leaks = canary_files(&records, false);
assert!(leaks.is_empty(), "canary in the record {leaks:?}: {what}");
}
dirs
}
fn assert_exempt_pre_image(fx: &Fixture, run_dir: &Path) {
let exempt: Vec<PathBuf> = files_below(run_dir)
.into_iter()
.filter(|rel| is_canary_exempt(rel))
.collect();
assert_eq!(
exempt,
[PathBuf::from("export/CapApp.zip")],
"the pre-image is the only exempt file"
);
let pre = run_dir.join("export/CapApp.zip");
assert!(
zip_holds_canary(&std::fs::read(&pre).expect("pre-image")),
"the exempt file holds the canary (the exemption is not vacuous)"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode(&pre), 0o600, "the pre-image is 0600");
assert_eq!(mode(&run_dir.join("export")), 0o700);
assert_eq!(mode(run_dir), 0o700, "inside a 0700 run directory");
}
let status = std::process::Command::new("git")
.arg("-C")
.arg(&fx.repo)
.args(["check-ignore", "-q", "--"])
.arg(&pre)
.status()
.expect("git check-ignore");
assert_eq!(status.code(), Some(0), "git ignores the pre-image");
}
async fn canary_apply(human: bool) {
let what = if human { "apply, human" } else { "apply, json" };
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
if human {
args.remove(0);
}
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(0),
"{what}: {}",
String::from_utf8_lossy(&out.stderr)
);
let imports = position(&methods(&server).await, "POST", "/import/");
assert_eq!(imports.len(), 1, "{what}");
assert_streams_canary_free(&out, what);
let dirs = assert_run_files_canary_free(&fx, what);
assert_eq!(dirs.len(), 1, "{what}");
assert_exempt_pre_image(&fx, &dirs[0]);
let record = std::fs::read_to_string(record_path(&fx)).expect("record");
assert!(record.contains("v1.1.0"), "{what}: {record}");
}
#[tokio::test]
async fn projects_canary_apply_json_and_human() {
canary_apply(false).await;
canary_apply(true).await;
}
async fn canary_unverified(human: bool) {
let what = if human {
"unverified, human"
} else {
"unverified, json"
};
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let record_before = std::fs::read(record_path(&fx)).expect("record");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_BAD, true),
)
.await;
let mut args = apply_args(&fx, "v1.1.0", Some(&hash), true);
if human {
args.remove(0);
}
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(6),
"{what}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("imported_unverified"),
"{what}: the verdict path ran"
);
assert_streams_canary_free(&out, what);
let dirs = assert_run_files_canary_free(&fx, what);
assert_eq!(dirs.len(), 1, "{what}");
assert_exempt_pre_image(&fx, &dirs[0]);
assert_eq!(
std::fs::read(record_path(&fx)).expect("record"),
record_before,
"{what}: no record for an unverified import"
);
}
#[tokio::test]
async fn projects_canary_check_and_verdict() {
for human in [false, true] {
let what = if human { "check, human" } else { "check, json" };
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let server = MockServer::start().await;
mount_list(&server, &["CapApp"]).await;
mount_find_found(&server).await;
mount_export(&server, export_zip(CANARY_LIVE, true)).await;
let mut args = args_with(&fx, "v1.1.0", &[]);
if human {
args.remove(0);
}
let out = run(&server, &args);
assert_eq!(
out.status.code(),
Some(0),
"{what}: {}",
String::from_utf8_lossy(&out.stderr)
);
let requests = methods(&server).await;
assert!(
!position(&requests, "GET", "/export/").is_empty(),
"{what}: {requests:?}"
);
if !human {
let envelope: Value = serde_json::from_slice(&out.stdout).expect("envelope");
assert_eq!(envelope["data"]["action"], "import", "{what}");
}
assert_streams_canary_free(&out, what);
assert!(
!fx.repo.join(".ign").exists(),
"{what}: a check writes no run"
);
assert!(assert_run_files_canary_free(&fx, what).is_empty());
}
canary_unverified(false).await;
canary_unverified(true).await;
}
struct ExportMoves {
calls: Arc<std::sync::atomic::AtomicUsize>,
first: Vec<u8>,
later: Vec<u8>,
}
impl Respond for ExportMoves {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
let zip = if n == 0 { &self.first } else { &self.later };
ResponseTemplate::new(200)
.set_body_raw(zip.clone(), "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"CapApp-export.zip\"",
)
}
}
fn assert_canary_refusal(out: &std::process::Output, code: &str) {
assert_eq!(
out.status.code(),
Some(6),
"{code}: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(out);
assert_eq!(err["error"]["code"], code);
assert_streams_canary_free(out, code);
}
#[tokio::test]
async fn projects_canary_refusals() {
{
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
let zeros = "0".repeat(64);
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&zeros), true));
assert_canary_refusal(&out, "project_hash_mismatch");
assert!(methods(&server).await.is_empty(), "no request was sent");
assert!(!fx.repo.join(".ign").exists(), "no run directory");
}
{
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
mount_designers(&server, &[("alice", "CapApp", "192.0.2.5:52526")]).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_canary_refusal(&out, "deploy_preflight_failed");
let requests = methods(&server).await;
assert!(!position(&requests, "GET", "/export/").is_empty());
assert!(position(&requests, "POST", "/import/").is_empty());
assert!(!fx.repo.join(".ign").exists(), "no run directory");
}
{
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
mount_gateway_name(&server, "somewhere-else", 1).await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_canary_refusal(&out, "deploy_preflight_failed");
let requests = methods(&server).await;
assert!(!position(&requests, "GET", "/export/").is_empty());
assert!(position(&requests, "POST", "/import/").is_empty());
assert!(!fx.repo.join(".ign").exists(), "no run directory");
}
{
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let record_before = std::fs::read(record_path(&fx)).expect("record");
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
Mock::given(method("GET"))
.and(path("/data/api/v1/projects/export/CapApp"))
.respond_with(ExportMoves {
calls: Arc::default(),
first: export_zip(CANARY_LIVE, true),
later: export_zip(CANARY_BAD, true),
})
.with_priority(1)
.mount(&server)
.await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_canary_refusal(&out, "plan_stale");
let requests = methods(&server).await;
assert!(
position(&requests, "POST", "/import/").is_empty(),
"nothing is imported: {requests:?}"
);
let dirs = assert_run_files_canary_free(&fx, "plan_stale");
assert_eq!(dirs.len(), 1, "plan_stale");
assert_exempt_pre_image(&fx, &dirs[0]);
let result: Value = serde_json::from_slice(
&std::fs::read(dirs[0].join("result.json")).expect("result.json"),
)
.expect("result.json is json");
assert_eq!(result["slug"], "plan_stale");
assert_eq!(
std::fs::read(record_path(&fx)).expect("record"),
record_before,
"no record for a stale run"
);
}
{
let fx = canary_fixture();
git_ignore_runs(&fx);
put_record(&fx, "v1.0.0");
let hash = artifact_hash(&fx);
let record_before = std::fs::read(record_path(&fx)).expect("record");
let server = MockServer::start().await;
mount_deployable(
&server,
Some(export_zip(CANARY_LIVE, true)),
export_zip(CANARY_CHECKOUT, true),
)
.await;
Mock::given(method("POST"))
.and(path("/data/api/v1/projects/import/CapApp"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"success": false,
"problem": "the gateway refused the import"
})))
.with_priority(1)
.mount(&server)
.await;
let out = run(&server, &apply_args(&fx, "v1.1.0", Some(&hash), true));
assert_canary_refusal(&out, "import_denied");
let requests = methods(&server).await;
assert_eq!(
position(&requests, "POST", "/import/").len(),
1,
"the import was sent: {requests:?}"
);
let dirs = assert_run_files_canary_free(&fx, "import_denied");
assert_eq!(dirs.len(), 1, "import_denied");
assert_exempt_pre_image(&fx, &dirs[0]);
let result: Value = serde_json::from_slice(
&std::fs::read(dirs[0].join("result.json")).expect("result.json"),
)
.expect("result.json is json");
assert_eq!(result["slug"], "import_denied");
assert_eq!(
std::fs::read(record_path(&fx)).expect("record"),
record_before,
"no record for a denied import"
);
}
}
fn add_script_reading_ghost_provider(fx: &Fixture) {
put(
&fx.repo,
"ignition/script-python/ghost/resource.json",
br#"{"scope":"A","version":1,"files":["code.py"],"attributes":{}}"#,
);
put(
&fx.repo,
"ignition/script-python/ghost/code.py",
b"TAGS = [\"[ghost]Area/Remote\"]\n",
);
}
async fn mount_default_with_present_only(server: &MockServer) {
mount_tag_providers(server, &["default"]).await;
mount_tag_export(
server,
"default",
json!({
"name": "",
"tagType": "Provider",
"tags": [{
"name": "Area",
"tagType": "Folder",
"tags": [{"name": "Present", "tagType": "AtomicTag"}]
}]
}),
)
.await;
}
fn tag_export_requests(requests: &[(String, String)]) -> usize {
requests
.iter()
.filter(|(_, url)| url.ends_with("/tags/export"))
.count()
}
#[tokio::test]
async fn projects_apply_refusal_names_the_missing_tags() {
let fx = fixture();
add_timer_reading_tags(&fx);
add_script_reading_ghost_provider(&fx);
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_default_with_present_only(&server).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), false));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "confirmation_required");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["[default]Area/Absent", "ghost"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert!(
!message.contains("[default]Area/Present"),
"a tag the gateway has is not named: {message}"
);
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_apply_refusal_names_a_file_over_the_read_limit() {
let fx = fixture();
add_script_over_the_read_limit(&fx);
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), false));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "confirmation_required");
let message = err["error"]["message"].as_str().expect("message");
for needle in ["NOT CHECKED", "ignition/script-python/big/code.py"] {
assert!(message.contains(needle), "{needle}: {message}");
}
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_missing_tags_export_refused_exits_zero_and_says_not_checked() {
let fx = fixture();
add_timer_reading_tags(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_tag_providers(&server, &["default"]).await;
Mock::given(method("GET"))
.and(path("/data/api/v1/tags/export"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["action"], "create");
assert!(data["missing_tags"].is_null(), "{data}");
assert!(data["missing_tags_count"].is_null(), "{data}");
let unavailable = data["missing_tags_unavailable"]
.as_array()
.expect("unavailable");
assert_eq!(unavailable.len(), 1, "{data}");
assert_eq!(unavailable[0]["provider"], "default");
let reason = unavailable[0]["reason"].as_str().expect("reason");
assert!(reason.contains("auth_rejected"), "{reason}");
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("NOT CHECKED"), "{text}");
assert!(!text.contains("no missing"), "{text}");
}
#[tokio::test]
async fn projects_check_a_provider_root_with_no_tags_says_not_checked() {
let fx = fixture();
add_timer_reading_tags(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_tag_providers(&server, &["default"]).await;
mount_tag_export(
&server,
"default",
json!({"name": "", "tagType": "Provider"}),
)
.await;
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("tags: NOT CHECKED (default): the provider's tag export is not a tag tree"),
"{text}"
);
assert!(!text.contains("no missing"), "{text}");
}
async fn mount_default_with_an_unresolved_udt(server: &MockServer) {
mount_tag_providers(server, &["default"]).await;
mount_tag_export(
server,
"default",
json!({
"name": "",
"tagType": "Provider",
"tags": [{
"name": "Area",
"tagType": "UdtInstance",
"typeId": "No/Such",
"tags": []
}]
}),
)
.await;
}
#[tokio::test]
async fn projects_check_an_unresolved_udt_type_says_not_checked() {
let fx = fixture();
add_timer_reading_tags(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_default_with_an_unresolved_udt(&server).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert!(data["missing_tags"].is_null(), "{data}");
assert!(data["missing_tags_count"].is_null(), "{data}");
let unavailable = data["missing_tags_unavailable"]
.as_array()
.expect("unavailable");
assert_eq!(unavailable.len(), 1, "{data}");
assert_eq!(unavailable[0]["provider"], "default", "{data}");
let reason = unavailable[0]["reason"].as_str().expect("reason");
assert!(
reason.contains("[default]Area/Absent, [default]Area/Present"),
"{reason}"
);
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains(
"tags: NOT CHECKED (default): the provider's tag export does not resolve the UDT type of 2 tag path(s)"
),
"{text}"
);
assert!(!text.contains("no missing"), "{text}");
let fx = fixture();
add_timer_reading_tags(&fx);
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_default_with_an_unresolved_udt(&server).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), false));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "confirmation_required");
let message = err["error"]["message"].as_str().expect("message");
assert!(
message.contains("tags NOT CHECKED for default"),
"{message}"
);
assert_only_gets(&methods(&server).await);
}
#[tokio::test]
async fn projects_check_a_file_over_the_read_limit_is_not_clean() {
let fx = fixture();
add_script_over_the_read_limit(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert!(data["missing_tags"].is_null(), "{data}");
assert!(data["missing_tags_count"].is_null(), "{data}");
let unavailable = data["missing_tags_unavailable"]
.as_array()
.expect("unavailable");
assert_eq!(unavailable.len(), 1, "{data}");
assert!(unavailable[0]["provider"].is_null(), "{data}");
let reason = unavailable[0]["reason"].as_str().expect("reason");
assert!(
reason.contains("ignition/script-python/big/code.py"),
"{reason}"
);
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains(
"tags: NOT CHECKED (every provider): the part past the 16 MiB read limit of 1 project file(s): ignition/script-python/big/code.py"
),
"{text}"
);
assert!(!text.contains("no missing"), "{text}");
}
#[cfg(unix)]
const FAKING_DIR: &str = "a\nartifact_sha256 fake\u{1b}[2K\u{202e}z";
#[cfg(unix)]
const FAKING_DIR_WRITTEN: &str = "a\\nartifact_sha256 fake\\u{1b}[2K\\u{202e}z";
#[cfg(unix)]
#[tokio::test]
async fn projects_apply_refusal_a_file_name_cannot_fake_a_line() {
let fx = fixture();
add_script_over_the_read_limit_in(&fx, FAKING_DIR);
git_ignore_runs(&fx);
let hash = artifact_hash(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let out = run(&server, &apply_args(&fx, "v1.0.0", Some(&hash), false));
assert_eq!(
out.status.code(),
Some(2),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let err = error_of(&out);
assert_eq!(err["error"]["code"], "confirmation_required");
let message = err["error"]["message"].as_str().expect("message");
assert_one_hash_line(message, &hash);
let not_checked: Vec<&str> = message
.lines()
.filter(|line| line.starts_with("tags NOT CHECKED"))
.collect();
assert_eq!(not_checked.len(), 1, "{message}");
assert!(not_checked[0].contains(FAKING_DIR_WRITTEN), "{message}");
assert!(!message.contains('\u{1b}'), "{message:?}");
assert!(!message.contains('\u{202e}'), "{message:?}");
let mut args = apply_args(&fx, "v1.0.0", Some(&hash), false);
args.retain(|a| a != "--json");
let out = run(&server, &args);
assert_eq!(out.status.code(), Some(2));
let text = String::from_utf8_lossy(&out.stderr);
assert_one_hash_line(&text, &hash);
assert!(text.contains(FAKING_DIR_WRITTEN), "{text}");
assert!(!text.contains('\u{1b}'), "{text:?}");
assert!(!text.contains('\u{202e}'), "{text:?}");
assert_only_gets(&methods(&server).await);
}
#[cfg(unix)]
fn assert_one_hash_line(text: &str, hash: &str) {
let lines: Vec<&str> = text
.lines()
.map(str::trim_start)
.filter(|line| line.starts_with("artifact_sha256 "))
.collect();
assert_eq!(lines.len(), 1, "{text}");
assert!(lines[0].contains(hash), "{text}");
}
#[cfg(unix)]
#[tokio::test]
async fn projects_check_a_file_name_is_written_out_in_every_output() {
let fx = fixture();
add_script_over_the_read_limit_in(&fx, FAKING_DIR);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
let unavailable = data["missing_tags_unavailable"]
.as_array()
.expect("unavailable");
assert_eq!(unavailable.len(), 1, "{data}");
let reason = unavailable[0]["reason"].as_str().expect("reason");
assert!(reason.contains(FAKING_DIR_WRITTEN), "{reason:?}");
assert!(!reason.contains('\n'), "{reason:?}");
for warning in data["warnings"].as_array().expect("warnings") {
let warning = warning.as_str().expect("a warning is text");
for bad in ['\n', '\u{1b}', '\u{202e}'] {
assert!(!warning.contains(bad), "{warning:?}");
}
}
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
let block: Vec<&str> = text
.lines()
.filter(|line| line.starts_with("tags: NOT CHECKED"))
.collect();
assert_eq!(block.len(), 1, "{text}");
assert!(block[0].contains(FAKING_DIR_WRITTEN), "{text}");
assert!(!text.contains('\u{1b}'), "{text:?}");
assert!(!text.contains('\u{202e}'), "{text:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn projects_check_a_file_name_is_written_out_in_files() {
let fx = fixture();
put(
&fx.repo,
"ignition/script-python/a\u{202e}b/resource.json",
br#"{"scope":"A","version":1,"files":["code.py"],"attributes":{}}"#,
);
put(
&fx.repo,
"ignition/script-python/a\u{202e}b/code.py",
b"TAGS = [\"[default]Area/Absent\"]\n",
);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_tag_providers(&server, &["default"]).await;
mount_tag_export(
&server,
"default",
json!({"name": "", "tagType": "Provider", "tags": []}),
)
.await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
let file = data["missing_tags"][0]["files"][0]
.as_str()
.expect("a file name");
assert_eq!(file, "ignition/script-python/a\\u{202e}b/code.py", "{data}");
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("[default]Area/Absent"), "{text}");
assert!(!text.contains('\u{202e}'), "{text:?}");
}
fn human_args(fx: &Fixture) -> Vec<String> {
let mut args = check_args(fx, "dev");
args.retain(|a| a != "--json");
args
}
#[tokio::test]
async fn projects_check_missing_tags_unknown_provider_is_reported_and_costs_no_export() {
let fx = fixture();
add_script_reading_ghost_provider(&fx);
let server = MockServer::start().await;
mount_list(&server, &[]).await;
mount_find_missing(&server).await;
mount_default_with_present_only(&server).await;
let data = data_of(&run(&server, &check_args(&fx, "dev")));
assert_eq!(data["missing_tags"], json!([]));
assert_eq!(
data["missing_tags_unknown_providers"],
json!([{
"provider": "ghost",
"path_count": 1,
"files": ["ignition/script-python/ghost/code.py"]
}])
);
assert_eq!(data["missing_tags_unknown_providers_count"], 1);
assert!(data["missing_tags_unavailable"].is_null(), "{data}");
let warnings = data["warnings"].as_array().expect("warnings");
assert!(
warnings
.iter()
.any(|w| w.as_str().is_some_and(|w| w.contains("ghost"))),
"{warnings:?}"
);
assert_eq!(tag_export_requests(&methods(&server).await), 0);
let out = run(&server, &human_args(&fx));
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("ghost"), "{text}");
assert!(text.contains("warning: "), "{text}");
assert!(!text.contains("no missing"), "{text}");
assert_eq!(tag_export_requests(&methods(&server).await), 0);
}