net-cli 0.38.2

Unified `net-mesh` command-line tool for the Net mesh
//! Temporary supervisors must never masquerade as deployed nodes.

use assert_cmd::Command;
use std::time::Duration;

const SCOPE: &str =
    "Starts a temporary supervisor for this command; does not inspect a running node.";

const COMMANDS: &[&[&str]] = &[
    &["admin", "drain", "1", "--drain-for", "1s"],
    &["admin", "enter-maintenance", "1"],
    &["admin", "exit-maintenance", "1"],
    &["admin", "cordon", "1"],
    &["admin", "uncordon", "1"],
    &["admin", "drop-replicas", "1", "--chain", "2"],
    &["admin", "invalidate-placement", "1"],
    &["admin", "restart-all-daemons", "1"],
    &["admin", "clear-avoid-list", "1"],
    &["ice", "freeze-cluster", "--ttl", "1s"],
    &["ice", "thaw-cluster"],
    &["ice", "flush-avoid-lists", "--scope", "global"],
    &["ice", "force-evict-replica", "1", "2"],
    &["ice", "force-restart-daemon", "1", "--name", "test"],
    &["ice", "force-cutover", "1", "2"],
    &["ice", "kill-migration", "1"],
    &["snapshot", "get"],
    &["snapshot", "status"],
    &["audit", "recent"],
    &["audit", "stream"],
    &["log", "tail"],
    &["failures", "tail"],
    &["cap", "show"],
    &["cap", "query", "--tag", "example"],
    &["cap", "nodes"],
    &["peer", "ls"],
    &["daemon", "ls"],
    &["subnet", "show"],
    &["subnet", "ls"],
    &["subnet", "tree"],
    &["gateway", "stats"],
    &["gateway", "exports"],
    &["channel", "visibility", "example"],
    &["channel", "ls"],
    &["aggregator", "inspect"],
    &["aggregator", "ls"],
];

fn config() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(&path, "").unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
    }
    dir
}

fn run(args: &[&str], config: &std::path::Path) -> std::process::Output {
    Command::cargo_bin("net-mesh")
        .unwrap()
        .timeout(Duration::from_secs(10))
        .args(args)
        .arg("--config")
        .arg(config.join("config.toml"))
        .args(["--output", "json", "--no-color"])
        .assert()
        .get_output()
        .clone()
}

#[test]
fn all_temporary_commands_inspect_without_startup_or_commit() {
    let dir = config();
    for (index, command) in COMMANDS.iter().enumerate() {
        let mut args = command.to_vec();
        args.push("--inspect-target");
        assert_eq!(run(&args, dir.path()).status.code(), Some(2), "{args:?}");
        args.push("--local");
        let out = run(&args, dir.path());
        assert!(
            out.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        let view: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
        assert_eq!(view["mode"], "temporary_supervisor", "{args:?}");
        assert_eq!(view["identity_required"], index < 16, "{args:?}");
        assert_eq!(view["identity"]["state"], "unavailable");
        assert!(view["supervisor_node_id"].is_u64());
        assert_eq!(view["authorization"], "not_checked");
        assert!(view["target"].is_null() && view["bind"].is_null());
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(
            !stderr.contains(SCOPE),
            "inspection announced startup: {args:?}"
        );
        assert!(!stderr.contains("ephemeral") && !stderr.contains("YES"));
        if index < 16 {
            assert!(view["identity"]["reason"]
                .as_str()
                .unwrap()
                .contains("requires"));
            args.push("--dry-run");
            assert_eq!(run(&args, dir.path()).status.code(), Some(2));
            args.pop();
        }
        args.extend(["--node-addr", "127.0.0.1:9"]);
        assert_eq!(run(&args, dir.path()).status.code(), Some(2), "{args:?}");
    }
    assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}

#[test]
fn every_temporary_operation_refuses_without_opt_in() {
    let dir = config();
    for args in COMMANDS {
        let out = run(args, dir.path());
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert_eq!(out.status.code(), Some(2), "{args:?}: {stderr}");
        assert!(out.stdout.is_empty(), "false success from {args:?}");
        assert!(stderr.contains(SCOPE), "{args:?}: {stderr}");
        assert!(stderr.contains("--local"), "{args:?}: {stderr}");
    }
}

#[test]
fn every_temporary_leaf_help_explains_scope() {
    let dir = config();
    for args in COMMANDS {
        let mut argv = args.to_vec();
        argv.push("--help");
        let out = run(&argv, dir.path());
        assert!(out.status.success(), "{argv:?}");
        let help = String::from_utf8_lossy(&out.stdout)
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ");
        assert!(help.contains(SCOPE), "{argv:?}: {help}");
    }
}

#[test]
fn explicit_local_reads_keep_json_shapes_and_disclose_scope() {
    let dir = config();
    for args in &COMMANDS[16..] {
        if matches!(
            *args,
            ["audit", "stream"] | ["log", "tail"] | ["failures", "tail"]
        ) {
            continue;
        }
        let mut argv = args.to_vec();
        argv.push("--local");
        let out = run(&argv, dir.path());
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(out.status.success(), "{argv:?}: {stderr}");
        let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
        assert!(value.is_object() || value.is_array(), "{argv:?}: {value}");
        assert!(value.get("execution_scope").is_none());
        assert!(stderr.contains(SCOPE), "{argv:?}: {stderr}");
    }
}

#[test]
fn admin_offline_preview_needs_no_configuration_but_checks_explicit_selection() {
    let dir = config();
    std::fs::write(dir.path().join("config.toml"), "invalid=[").unwrap();
    let out = run(&["admin", "cordon", "1", "--dry-run"], dir.path());
    assert!(!out.status.success());
    assert!(out.stdout.is_empty());
    let out = Command::cargo_bin("net-mesh")
        .unwrap()
        .env_remove("NET_MESH_CONFIG")
        .env_remove("NET_MESH_PROFILE")
        .args(["admin", "cordon", "1", "--dry-run", "--output", "json"])
        .assert()
        .get_output()
        .clone();
    assert!(out.status.success());
    let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(value["dry_run"], true);
    assert!(!String::from_utf8_lossy(&out.stderr).contains(SCOPE));
}

#[test]
fn local_signed_admin_and_ice_simulation_work_with_identity() {
    let dir = config();
    let identity = dir.path().join("identity.toml");
    Command::cargo_bin("net-mesh")
        .unwrap()
        .args(["identity", "generate", "--out"])
        .arg(&identity)
        .assert()
        .success();
    for args in &COMMANDS[..16] {
        let mut argv = args.to_vec();
        argv.extend(["--local", "--identity", identity.to_str().unwrap()]);
        if args[0] == "ice" {
            argv.push("--dry-run");
        }
        let out = run(&argv, dir.path());
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(out.status.success(), "{args:?}: {stderr}");
        let _: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
        assert!(stderr.contains(SCOPE), "{args:?}: {stderr}");
    }
}

#[test]
fn local_streams_disclose_scope_and_stay_running() {
    use std::io::{BufRead, BufReader};
    use std::process::Stdio;
    use std::sync::mpsc;

    let dir = config();
    for args in [["audit", "stream"], ["log", "tail"], ["failures", "tail"]] {
        let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin("net-mesh"))
            .args(args)
            .args(["--local", "--quiet", "--output", "ndjson", "--config"])
            .arg(dir.path().join("config.toml"))
            .env("NET_MESH_LOG", "off")
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        let stderr = child.stderr.take().unwrap();
        let (tx, rx) = mpsc::channel();
        let reader = std::thread::spawn(move || {
            for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                if line.contains(SCOPE) {
                    let _ = tx.send(());
                }
            }
        });
        let notice = rx.recv_timeout(Duration::from_secs(10));
        std::thread::sleep(Duration::from_millis(100));
        // Always reap even if the assertion will fail.
        let early_exit = child.try_wait().unwrap();
        let _ = child.kill();
        let out = child.wait_with_output().unwrap();
        reader.join().unwrap();
        assert!(notice.is_ok(), "{args:?} missing scope notice");
        assert!(early_exit.is_none(), "{args:?} exited before streaming");
        assert!(out.stdout.is_empty(), "scope leaked onto stream stdout");
    }
}

#[test]
fn local_remote_conflicts_refuse_and_gateway_export_stays_unsupported() {
    let dir = config();
    for flags in [
        &["--remote"][..],
        &["--node-id", "7"],
        &["--node-addr", "127.0.0.1:9"],
    ] {
        let mut args = vec!["aggregator", "ls", "--local"];
        args.extend_from_slice(flags);
        let out = run(&args, dir.path());
        assert_eq!(out.status.code(), Some(2));
        assert!(out.stdout.is_empty());
        assert!(String::from_utf8_lossy(&out.stderr).contains("conflicts"));
    }
    let out = run(&["gateway", "export", "example", "global"], dir.path());
    assert_eq!(out.status.code(), Some(2));
    assert!(out.stdout.is_empty());
    assert!(String::from_utf8_lossy(&out.stderr).contains("read-validation-only"));
}

#[test]
fn generated_man_and_completion_include_local_scope() {
    let dir = config();
    let man = run(&["man"], dir.path());
    assert!(man.status.success());
    let text = String::from_utf8_lossy(&man.stdout)
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ");
    assert!(
        text.contains(SCOPE),
        "man page lost the scope sentence: {text}"
    );
    let completion = run(&["completion", "bash"], dir.path());
    assert!(completion.status.success());
    let text = String::from_utf8_lossy(&completion.stdout);
    // Scope the check to one sample leaf's own completion entry (the opts
    // arm for `cap show`), not the whole script.
    let arm = "net__subcmd__mesh__subcmd__cap__subcmd__show)";
    let rest = text
        .split_once(arm)
        .unwrap_or_else(|| panic!("sample leaf completion entry not found: {arm}"))
        .1;
    let entry = rest
        .lines()
        .find(|line| line.trim_start().starts_with("opts="))
        .expect("sample leaf completion entry carries an opts line");
    assert!(
        entry.contains("--local"),
        "sample leaf completion entry lost --local: {entry}"
    );
}