diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-07 19:33:11 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-07 19:33:11 +0300 |
| commit | 702aa1e45527509bec73dad9b8d443f449c0379b (patch) | |
| tree | 0f017fea62885a964c1aab16d1869979d8569021 /tests | |
| parent | e7d8220788b4fa7c266a1e4457591b694478771c (diff) | |
fix: require explicit consent for remote server replacement
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/machine_setup.rs | 238 |
1 files changed, 238 insertions, 0 deletions
diff --git a/tests/machine_setup.rs b/tests/machine_setup.rs new file mode 100644 index 00000000..752126fc --- /dev/null +++ b/tests/machine_setup.rs @@ -0,0 +1,238 @@ +#![cfg(unix)] + +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::sync::mpsc; +use std::time::Duration; + +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + +// No real SSH connection or server is started. Stop at startup after recording setup actions. +const SSH: &str = r#"#!/bin/sh +for arg do last=$arg; done +if [ "$last" = 'command -v herdr' ]; then + echo /home/remote/.local/bin/herdr + exit 0 +fi +case "$last" in + 'tee '*) cat >/dev/null; exit 0 ;; +esac +script=$(cat) +case "$script" in + *'uname -s'*) uname -s; uname -m ;; + *'version='*) echo /home/remote/.local/bin/herdr ;; + *'status client --json'*) + if [ "$FAKE_INSTALLED" = new ] || [ -f "$FAKE_ROOT/installed" ]; then + printf '%s\n' "$FAKE_CLIENT_STATUS" + else + echo '{"version":"0.8.2","protocol":20}' + fi ;; + *'status server --json'*) + if [ -f "$FAKE_ROOT/stopped" ]; then + echo '{"running":false}' + else + echo '{"running":true,"version":"0.8.2","capabilities":{"live_handoff":true,"detached_server_daemon":true}}' + fi ;; + *'server live-handoff'*) echo handoff >>"$FAKE_ROOT/actions"; exit 1 ;; + *'server stop'*) echo stop >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/stopped" ;; + *'remote-client-bridge'*) echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;; + *'mkdir -p'*) printf '/fake/tmp\000/fake/herdr\000' ;; + *'chmod 755'*) echo install >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/installed" ;; + *'command -v herdr'*) echo /home/remote/.local/bin/herdr ;; + *) echo "unexpected fake SSH script: $script" >&2; exit 1 ;; +esac +"#; + +struct SetupResult { + output: String, + actions: String, + prompts: usize, +} + +fn setup(installed: &str, answer: &str, handoff: bool) -> SetupResult { + let root = std::path::PathBuf::from(format!( + "/var/tmp/herdr-machine-setup-{}-{}-{}-{}", + std::process::id(), + installed, + answer.trim().is_empty(), + handoff + )); + let app = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + fs::create_dir_all(root.join("bin")).unwrap(); + fs::create_dir_all(root.join("config").join(app)).unwrap(); + fs::write(root.join("bin/ssh"), SSH).unwrap(); + fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::write( + root.join("config").join(app).join("config.toml"), + "onboarding = false\n[remote]\nmanage_ssh_config = false\n", + ) + .unwrap(); + let status = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["status", "client", "--json"]) + .output() + .unwrap(); + assert!(status.status.success()); + + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + if handoff { + command.args(["--remote", "fake-host", "--handoff"]); + } else { + command.args(["machine", "add", "fake-host", "--label", "VPS"]); + } + command.env( + "PATH", + format!("{}:/usr/bin:/bin", root.join("bin").display()), + ); + command.env("HOME", &root); + command.env("XDG_CONFIG_HOME", root.join("config")); + command.env("XDG_STATE_HOME", root.join("state")); + command.env("XDG_RUNTIME_DIR", &root); + command.env("FAKE_ROOT", &root); + command.env("FAKE_INSTALLED", installed); + command.env( + "FAKE_CLIENT_STATUS", + String::from_utf8(status.stdout).unwrap(), + ); + for name in [ + "HERDR_ENV", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + "HERDR_CLIENT_SOCKET_PATH", + "HERDR_REMOTE_BINARY", + "HERDR_CONFIG_PATH", + ] { + command.env_remove(name); + } + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut writer = pair.master.take_writer().unwrap(); + let (tx, rx) = mpsc::channel(); + let reading = std::thread::spawn(move || { + let mut buffer = [0; 4096]; + while let Ok(len) = reader.read(&mut buffer) { + if len == 0 || tx.send(buffer[..len].to_vec()).is_err() { + break; + } + } + }); + let mut output = String::new(); + let mut prompts = 0; + let mut timed_out = false; + loop { + match rx.recv_timeout(Duration::from_secs(20)) { + Ok(bytes) => { + output.push_str(&String::from_utf8_lossy(&bytes)); + let count = output.matches("[y/N]").count() + output.matches("[Y/n]").count(); + if count > prompts { + writer + .write_all(if prompts == 0 { + answer.as_bytes() + } else { + b"n\n" + }) + .unwrap(); + writer.flush().unwrap(); + prompts = count; + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => { + timed_out = true; + child.kill().unwrap(); + break; + } + } + } + child.wait().unwrap(); + drop(writer); + drop(pair.master); + reading.join().unwrap(); + let actions = fs::read_to_string(root.join("actions")).unwrap_or_default(); + // Every scenario either cancels or reaches the intentionally failed startup. + let saved = root + .join("state") + .join(app) + .join("client/endpoints.json") + .exists(); + fs::remove_dir_all(root).unwrap(); + assert!( + !saved, + "failed or cancelled setup saved the machine: {output}" + ); + assert!(!timed_out, "setup timed out: {output}"); + SetupResult { + output, + actions, + prompts, + } +} + +#[test] +fn machine_setup_old_install_requires_one_explicit_stop_approval() { + let result = setup("old", "y\n", false); + assert_eq!(result.prompts, 1, "{}", result.output); + assert!( + result + .output + .contains("This stops active remote pane processes"), + "{}", + result.output + ); + assert_eq!( + result.actions, "install\nstop\nstart\n", + "{}", + result.output + ); +} + +#[test] +fn machine_setup_old_install_enter_cancels_without_remote_changes() { + let result = setup("old", "\n", false); + assert!(result.actions.is_empty(), "{}", result.output); + assert!( + result.output.contains("machine was not saved"), + "{}", + result.output + ); +} + +#[test] +fn machine_setup_new_install_old_server_enter_does_not_stop_or_handoff() { + let result = setup("new", "\n", false); + assert!(result.actions.is_empty(), "{}", result.output); + assert!( + result + .output + .contains("This stops active remote pane processes"), + "{}", + result.output + ); +} + +#[test] +fn machine_setup_new_install_old_server_approval_stops_then_starts_without_installing() { + let result = setup("new", "y\n", false); + assert_eq!(result.prompts, 1, "{}", result.output); + assert_eq!(result.actions, "stop\nstart\n", "{}", result.output); +} + +#[test] +fn machine_setup_explicit_remote_handoff_remains_available_but_restart_fallback_defaults_to_no() { + let result = setup("new", "\n", true); + assert_eq!(result.actions, "handoff\n", "{}", result.output); + assert!( + result + .output + .contains("This stops active remote pane processes"), + "{}", + result.output + ); +} |
