diff options
| author | Eric Yue <jiacheng.yue@foxmail.com> | 2026-09-16 20:41:41 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-16 14:41:41 +0200 |
| commit | ab15da285e9d74bea476375f635f73148d551d24 (patch) | |
| tree | 57549bb52ebee34fa4015268710a7e6973193377 /tests | |
| parent | 68ae6adea0add496cea227030c94e5d3172f4e86 (diff) | |
fix: require exact session names before deletion (#4233)
* fix: require exact session names before deletion
refs #3819
* test: isolate session deletion regression tests
refs #3819
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/session_delete.rs | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/tests/session_delete.rs b/tests/session_delete.rs new file mode 100644 index 00000000..0aa80582 --- /dev/null +++ b/tests/session_delete.rs @@ -0,0 +1,180 @@ +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Owns a unique configuration directory; only child CLIs receive its environment. +struct SessionConfig { + root: PathBuf, +} + +impl SessionConfig { + fn new() -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + // Keep Unix socket paths below the host's length limit. + #[cfg(unix)] + let base = PathBuf::from("/tmp"); + #[cfg(not(unix))] + let base = std::env::temp_dir(); + let root = base.join(format!( + "hs-delete-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + Self { root } + } + + fn session_dir(&self, name: &str) -> PathBuf { + let app_dir = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + self.root.join(app_dir).join("sessions").join(name) + } + + fn create_session(&self, name: &str) -> PathBuf { + let dir = self.session_dir(name); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("session.json"), b"saved session").unwrap(); + dir + } + + fn delete(&self, name: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["session", "delete", name, "--json"]) + .env("XDG_CONFIG_HOME", &self.root) + .env_remove("HERDR_SESSION") + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .output() + .unwrap() + } +} + +impl Drop for SessionConfig { + fn drop(&mut self) { + // Cleanup must not cause a second panic if a test assertion failed. + let _ = fs::remove_dir_all(&self.root); + } +} + +fn assert_success(output: &Output) -> serde_json::Value { + assert!( + output.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["deleted"], true); + response +} + +#[test] +fn delete_session_preserves_differently_cased_name() { + let config = SessionConfig::new(); + let dir = config.create_session("Foo"); + let case_insensitive = config.session_dir("foo").exists(); + + let output = config.delete("foo"); + + assert_eq!( + fs::read(dir.join("session.json")).unwrap(), + b"saved session" + ); + if case_insensitive { + assert_eq!(output.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"]["code"], "session_delete_failed"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("exact")); + } else { + assert_success(&output); + } +} + +#[test] +fn delete_session_removes_exact_stopped_name_only() { + let config = SessionConfig::new(); + let dir = config.create_session("Foo"); + let other = config.create_session("other"); + + let response = assert_success(&config.delete("Foo")); + + assert_eq!(response["session"]["name"], "Foo"); + assert_eq!(response["session"]["running"], false); + assert!(!dir.exists()); + assert_eq!( + fs::read(other.join("session.json")).unwrap(), + b"saved session" + ); +} + +#[test] +fn delete_session_missing_name_remains_idempotent() { + let config = SessionConfig::new(); + assert_success(&config.delete("missing")); + config.create_session("other"); + assert_success(&config.delete("missing")); +} + +#[test] +fn delete_session_distinguishes_case_sensitive_siblings() { + let config = SessionConfig::new(); + let upper = config.create_session("Foo"); + if config.session_dir("foo").exists() { + return; // This filesystem cannot store both spellings independently. + } + let lower = config.create_session("foo"); + + assert_success(&config.delete("foo")); + + assert!(!lower.exists()); + assert_eq!( + fs::read(upper.join("session.json")).unwrap(), + b"saved session" + ); +} + +#[cfg(unix)] +#[test] +fn delete_session_preserves_running_session() { + let config = SessionConfig::new(); + let dir = config.create_session("Foo"); + let _listener = std::os::unix::net::UnixListener::bind(dir.join("herdr.sock")).unwrap(); + + let output = config.delete("Foo"); + + assert_eq!(output.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("running")); + assert_eq!( + fs::read(dir.join("session.json")).unwrap(), + b"saved session" + ); +} + +#[test] +fn session_config_cleans_up_after_panic() { + let mut root = None; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let config = SessionConfig::new(); + root = Some(config.root.clone()); + config.create_session("Foo"); + panic!("simulate a failed assertion"); + })); + + assert!(result.is_err()); + assert!(!root.unwrap().exists()); +} |
