aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-09-07 19:33:11 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-09-07 19:33:11 +0300
commit702aa1e45527509bec73dad9b8d443f449c0379b (patch)
tree0f017fea62885a964c1aab16d1869979d8569021 /tests
parente7d8220788b4fa7c266a1e4457591b694478771c (diff)
fix: require explicit consent for remote server replacement
Diffstat (limited to 'tests')
-rw-r--r--tests/machine_setup.rs238
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
+ );
+}