use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
fn cmd(tmp: &TempDir) -> Command {
let mut c = Command::cargo_bin("ssh-cli").unwrap();
c.env_clear();
c.env("HOME", tmp.path());
c.env("PATH", std::env::var("PATH").unwrap_or_default());
c.env("XDG_CONFIG_HOME", tmp.path());
c.args(["--config-dir", tmp.path().to_str().unwrap()]);
c
}
fn seed_host(tmp: &TempDir, name: &str) {
cmd(tmp)
.args([
"secrets",
"init",
"--json",
"--allow-plaintext-secrets",
])
.assert()
.success();
cmd(tmp)
.args([
"--allow-plaintext-secrets",
"vps",
"add",
"--name",
name,
"--host",
"127.0.0.1",
"--user",
"u",
"--password",
"pw-test-secret-not-real",
"--timeout",
"5000",
])
.assert()
.success();
}
#[test]
fn export_pipe_defaults_to_json_when_non_tty() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "e1");
let out = cmd(&tmp)
.args(["vps", "export"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.trim_start().starts_with('{') && s.contains("vps-export"),
"export pipe must be JSON envelope under non-TTY: {s}"
);
let toml_out = cmd(&tmp)
.args(["--output-format", "text", "vps", "export"])
.assert()
.success()
.get_output()
.stdout
.clone();
let t = String::from_utf8_lossy(&toml_out);
assert!(
t.contains("[hosts.") || t.contains("name =") || t.contains("schema_version"),
"export with --output-format text must be TOML, got: {t}"
);
}
#[test]
fn export_json_flag_envelope() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "e2");
cmd(&tmp)
.args(["vps", "export", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("vps-export"))
.stdout(predicate::str::contains("\"ok\""));
}
#[test]
fn export_import_toml_roundtrip() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args([
"--allow-plaintext-secrets",
"vps",
"add",
"--name",
"rt",
"--host",
"127.0.0.1",
"--user",
"u",
"--password",
"pw-roundtrip-plain",
])
.assert()
.success();
let export = tmp.path().join("exp.toml");
cmd(&tmp)
.args([
"--allow-plaintext-secrets",
"vps",
"export",
"--include-secrets",
"--output",
export.to_str().unwrap(),
])
.assert()
.success();
let text = std::fs::read_to_string(&export).unwrap();
assert!(
!text.contains("sshcli-enc:"),
"plaintext export expected: {text}"
);
let tmp2 = TempDir::new().unwrap();
cmd(&tmp2)
.args([
"--allow-plaintext-secrets",
"vps",
"import",
"--file",
export.to_str().unwrap(),
])
.assert()
.success();
cmd(&tmp2)
.args(["vps", "list"])
.assert()
.success()
.stdout(predicate::str::contains("rt"));
}
#[test]
fn import_english_fields() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args(["secrets", "init", "--allow-plaintext-secrets"])
.assert()
.success();
let f = tmp.path().join("en.toml");
fs::write(
&f,
r#"
schema_version = 3
[hosts.enhost]
name = "enhost"
host = "10.0.0.1"
port = 22
username = "admin"
password = "secret-en-only"
timeout_ms = 60000
schema_version = 3
"#,
)
.unwrap();
cmd(&tmp)
.args([
"--allow-plaintext-secrets",
"vps",
"import",
"--file",
f.to_str().unwrap(),
])
.assert()
.success();
cmd(&tmp)
.args(["vps", "show", "enhost"])
.assert()
.success()
.stdout(predicate::str::contains("10.0.0.1"));
}
#[test]
fn import_pt_without_added_at() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args(["secrets", "init", "--allow-plaintext-secrets"])
.assert()
.success();
let f = tmp.path().join("pt.toml");
fs::write(
&f,
r#"
schema_version = 2
[hosts.pthost]
nome = "pthost"
host = "10.0.0.2"
porta = 22
usuario = "root"
senha = "secret-pt"
timeout_ms = 60000
schema_version = 2
"#,
)
.unwrap();
cmd(&tmp)
.args([
"--allow-plaintext-secrets",
"vps",
"import",
"--file",
f.to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn import_bad_toml_exit_65() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("bad.toml");
fs::write(&f, "this is not = valid [toml").unwrap();
cmd(&tmp)
.args(["vps", "import", "--file", f.to_str().unwrap()])
.assert()
.failure()
.code(65);
}
#[test]
fn secrets_init_json_envelope() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args(["secrets", "init", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("secrets-init"))
.stdout(predicate::str::contains("\"ok\""));
}
#[test]
fn empty_command_english() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "c1");
let out = cmd(&tmp)
.args(["--lang", "en-US", "exec", "c1", " "])
.assert()
.failure()
.get_output()
.stderr
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("empty command") || s.contains("invalid argument"),
"expected EN empty command, got: {s}"
);
assert!(!s.contains("comando vazio"), "must not contain PT hardcode: {s}");
}
#[test]
fn crud_add_json_success_event() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args(["secrets", "init", "--allow-plaintext-secrets"])
.assert()
.success();
cmd(&tmp)
.args([
"--output-format",
"json",
"--allow-plaintext-secrets",
"vps",
"add",
"--name",
"j1",
"--host",
"1.1.1.1",
"--user",
"u",
"--password",
"pw-json-add",
])
.assert()
.success()
.stdout(predicate::str::contains("vps-added").or(predicate::str::contains("secrets-key-auto-created")));
}
#[test]
fn include_secrets_pipe_refused_without_ack() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "sec");
cmd(&tmp)
.args(["vps", "export", "--include-secrets"])
.assert()
.failure()
.code(64);
}
#[test]
fn wire_serialize_english_keys() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "w1");
let cfg = fs::read_to_string(tmp.path().join("config.toml")).unwrap();
assert!(cfg.contains("name =") || cfg.contains("[hosts.w1]"));
assert!(!cfg.contains("nome ="), "must not write PT nome: {cfg}");
assert!(!cfg.contains("porta ="), "must not write PT porta: {cfg}");
}
#[test]
fn health_check_all_empty_registry_exits_usage() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args([
"--max-concurrency",
"4",
"health-check",
"--all",
"--json",
])
.assert()
.failure()
.code(64)
.stderr(predicate::str::contains("no hosts registered for --all"))
.stderr(predicate::str::contains("\"exit_code\":64"));
}
#[test]
fn max_concurrency_with_exec_all_empty_registry() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args([
"--max-concurrency",
"2",
"exec",
"--all",
"true",
"--json",
])
.assert()
.failure()
.code(64);
}
#[test]
fn exec_hosts_empty_registry_exits_usage() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args([
"--max-concurrency",
"4",
"exec",
"--hosts",
"a,b",
"true",
"--json",
])
.assert()
.failure()
.code(64)
.stderr(predicate::str::contains("no hosts registered for --hosts"));
}
#[test]
fn health_check_hosts_unknown_exits_usage() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "real");
cmd(&tmp)
.args(["health-check", "--hosts", "ghost", "--json"])
.assert()
.failure()
.code(64)
.stderr(predicate::str::contains("unknown host(s) for --hosts"));
}
#[test]
fn exec_all_hosts_conflict() {
let tmp = TempDir::new().unwrap();
cmd(&tmp)
.args(["exec", "--all", "--hosts", "a", "true"])
.assert()
.failure();
}
#[test]
fn doctor_json_single_root_envelope() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "d1");
let out = cmd(&tmp)
.args(["vps", "doctor", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(
s.trim_start().starts_with('{'),
"doctor JSON must start with object: {s}"
);
assert!(
s.contains("\"event\":\"vps-doctor\"") || s.contains("\"event\": \"vps-doctor\""),
"event vps-doctor missing: {s}"
);
assert!(s.contains("\"local\""), "local nested missing: {s}");
assert!(
s.contains("\"ssh_probe\":null") || s.contains("\"ssh_probe\": null"),
"ssh_probe null missing: {s}"
);
assert!(
!s.contains("health-check-batch"),
"dual root / nested batch without probe unexpected: {s}"
);
}
#[test]
fn doctor_hosts_requires_probe_ssh() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "d2");
cmd(&tmp)
.args(["vps", "doctor", "--hosts", "d2", "--json"])
.assert()
.failure()
.code(64)
.stderr(predicate::str::contains("--probe-ssh"));
}
#[test]
fn scp_multi_file_with_all_parses_and_attempts_transfer() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "s1");
let a = tmp.path().join("a.bin");
let b = tmp.path().join("b.bin");
fs::write(&a, b"aa").unwrap();
fs::write(&b, b"bb").unwrap();
cmd(&tmp)
.args([
"scp",
"upload",
"--all",
a.to_str().unwrap(),
b.to_str().unwrap(),
"/tmp/out",
])
.assert()
.failure()
.code(predicate::ne(64))
.stdout(predicate::str::contains("scp-batch"));
}
#[test]
fn scp_multi_file_positionals_parsed() {
let tmp = TempDir::new().unwrap();
seed_host(&tmp, "s2");
let a = tmp.path().join("a.bin");
let b = tmp.path().join("b.bin");
fs::write(&a, b"aa").unwrap();
fs::write(&b, b"bb").unwrap();
let assert = cmd(&tmp)
.args([
"--max-concurrency",
"2",
"scp",
"upload",
"s2",
a.to_str().unwrap(),
b.to_str().unwrap(),
"/tmp",
"--json",
])
.assert()
.failure();
let code = assert.get_output().status.code();
assert_ne!(code, Some(2), "clap must accept multi-file positionals");
}