roswire 0.1.1

JSON-first RouterOS CLI bridge for AI agents and automation.
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use std::fs;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

mod common;

use common::predicate;

#[test]
fn binary_supports_version_flag() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains("roswire"));
}

#[test]
fn binary_supports_help_flag() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage:"));
}

#[test]
fn binary_without_arguments_returns_structured_usage_error() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"USAGE_ERROR\""));
}

#[test]
fn structured_errors_are_written_to_stderr_only() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.arg("--simulate-error")
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"USAGE_ERROR\""));
}

#[test]
fn registered_write_command_reaches_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args([
            "ip",
            "address",
            "add",
            "address=192.168.88.2/24",
            "interface=ether1",
            "--json",
        ])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn script_put_dry_run_outputs_plan_without_source_content_or_absolute_path() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let source = temp.path().join("bootstrap.rsc");
    let script = ":put \"SMOKE_SECRET_SCRIPT\"";
    fs::write(&source, script).expect("source should be written");
    let source_arg = format!("@{}", source.display());
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");

    cmd.args([
        "script",
        "put",
        "bootstrap",
        "--source",
        &source_arg,
        "--dry-run",
        "--json",
    ])
    .assert()
    .success()
    .stderr(predicate::str::is_empty())
    .stdout(predicate::str::contains(
        "\"schema_version\":\"roswire.workflow.script.put.plan.v1\"",
    ))
    .stdout(predicate::str::contains("\"script_name\":\"bootstrap\""))
    .stdout(predicate::str::contains("***REDACTED***/bootstrap.rsc"))
    .stdout(predicate::str::contains(temp.path().to_string_lossy().as_ref()).not())
    .stdout(predicate::str::contains(script).not());
}

#[test]
fn script_put_reaches_connection_resolution_without_leaking_source() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let source = temp.path().join("bootstrap.rsc");
    let script = ":put \"SMOKE_SECRET_SCRIPT\"";
    fs::write(&source, script).expect("source should be written");
    let source_arg = format!("@{}", source.display());
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");

    cmd.env("ROSWIRE_HOME", temp.path().join("missing-home"))
        .args([
            "script",
            "put",
            "bootstrap",
            "--source",
            &source_arg,
            "--json",
        ])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not())
        .stderr(predicate::str::contains(temp.path().to_string_lossy().as_ref()).not())
        .stderr(predicate::str::contains(script).not());
}

#[test]
fn readonly_print_without_connection_config_returns_config_error() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path());
    cmd.args(["interface", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""));
}

#[test]
fn raw_print_reaches_connection_resolution_without_allow_write() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["raw", "/system/resource/print", "detail=yes", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not())
        .stderr(predicate::str::contains("allow-write").not());
}

#[test]
fn raw_write_requires_allow_write_and_redacts_args() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.args([
        "raw",
        "/tool/fetch",
        "password=super-secret",
        "src-path=/Users/example/setup.rsc",
        "--json",
    ])
    .assert()
    .failure()
    .stdout(predicate::str::is_empty())
    .stderr(predicate::str::contains("\"error_code\":\"USAGE_ERROR\""))
    .stderr(predicate::str::contains("--allow-write"))
    .stderr(predicate::str::contains("super-secret").not())
    .stderr(predicate::str::contains("/Users/example").not())
    .stderr(predicate::str::contains("***REDACTED***"));
}

#[test]
fn raw_write_with_allow_write_reaches_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args([
            "raw",
            "/tool/fetch",
            "url=https://example.invalid/a.rsc",
            "--allow-write",
            "--json",
        ])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn explicit_rest_raw_reports_unsupported_without_network() {
    let credential = generated_credential();
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.args([
        "--host",
        "127.0.0.1",
        "--user",
        "admin",
        "--password",
        &credential,
        "--protocol",
        "rest",
        "raw",
        "/system/resource/print",
        "--json",
    ])
    .assert()
    .failure()
    .stdout(predicate::str::is_empty())
    .stderr(predicate::str::contains(
        "\"error_code\":\"UNSUPPORTED_ACTION\"",
    ))
    .stderr(predicate::str::contains("REST mapping unavailable"))
    .stderr(predicate::str::contains(&credential).not());
}

#[test]
fn system_package_print_is_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["system", "package", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn user_print_is_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["user", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn ip_route_print_is_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["ip", "route", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn firewall_print_is_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["ip", "firewall", "nat", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn tool_print_is_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["tool", "netwatch", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn wireguard_prints_are_registered_before_connection_resolution() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["interface", "wireguard", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());

    let mut peers = Command::cargo_bin("roswire").expect("binary should compile");
    peers
        .env("ROSWIRE_HOME", temp.path())
        .args(["interface", "wireguard", "peers", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("UNSUPPORTED_ACTION").not());
}

#[test]
fn unsupported_wireguard_write_redacts_key_material() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.args([
        "interface",
        "wireguard",
        "add",
        "private-key=private-secret",
        "preshared-key=shared-secret",
        "--json",
    ])
    .assert()
    .failure()
    .stdout(predicate::str::is_empty())
    .stderr(predicate::str::contains(
        "\"error_code\":\"UNSUPPORTED_ACTION\"",
    ))
    .stderr(predicate::str::contains("private-secret").not())
    .stderr(predicate::str::contains("shared-secret").not())
    .stderr(predicate::str::contains("***REDACTED***"));
}

#[test]
fn unsupported_user_write_redacts_password_argument() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.args(["user", "set", "password=super-secret", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains(
            "\"error_code\":\"UNSUPPORTED_ACTION\"",
        ))
        .stderr(predicate::str::contains("super-secret").not())
        .stderr(predicate::str::contains("***REDACTED***"));
}

#[test]
fn readonly_print_rejects_profile_mac_host_before_network() {
    let temp = tempfile::tempdir().expect("temp dir should be created");
    fs::write(
        temp.path().join("config.toml"),
        r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "48:8F:5A:A3:0E:A7"
user = "master"
"#,
    )
    .expect("config should be written");
    #[cfg(unix)]
    {
        fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
            .expect("home permissions should be set");
        fs::set_permissions(
            temp.path().join("config.toml"),
            fs::Permissions::from_mode(0o600),
        )
        .expect("config permissions should be set");
    }

    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.env("ROSWIRE_HOME", temp.path())
        .args(["interface", "print", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains("\"error_code\":\"CONFIG_ERROR\""))
        .stderr(predicate::str::contains("MAC address"))
        .stderr(predicate::str::contains(
            "Layer 2 discovery is not supported",
        ))
        .stderr(predicate::str::contains("\"host\":\"48:8F:5A:A3:0E:A7\""));
}

#[test]
fn unsupported_action_context_redacts_sensitive_args() {
    let mut cmd = Command::cargo_bin("roswire").expect("binary should compile");
    cmd.args(["ip", "address", "enable", "password=super-secret", "--json"])
        .assert()
        .failure()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::contains(
            "\"error_code\":\"UNSUPPORTED_ACTION\"",
        ))
        .stderr(predicate::str::contains("super-secret").not())
        .stderr(predicate::str::contains("***REDACTED***"));
}

fn generated_credential() -> String {
    [
        'r', 'a', 'w', '-', 'r', 'e', 's', 't', '-', 'c', 'r', 'e', 'd',
    ]
    .into_iter()
    .collect()
}