#![cfg(all(unix, feature = "client"))]
use std::{
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread::sleep,
time::{Duration, Instant},
};
use serde_json::Value;
use tempfile::TempDir;
fn serve(home: &Path) -> Child {
Command::new(env!("CARGO_BIN_EXE_heddle"))
.args(["netd", "serve"])
.current_dir(home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env("HOME", home)
.env("HEDDLE_HOME", home)
.env_remove("HEDDLE_CONFIG")
.env_remove("HEDDLE_CREDENTIAL")
.env("NO_COLOR", "1")
.spawn()
.expect("spawn `heddle netd serve`")
}
fn run(home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_heddle"))
.args(args)
.current_dir(home)
.stdin(Stdio::null())
.env("HOME", home)
.env("HEDDLE_HOME", home)
.env_remove("HEDDLE_CONFIG")
.env_remove("HEDDLE_CREDENTIAL")
.env("NO_COLOR", "1")
.output()
.expect("run `heddle netd` verb")
}
fn endpoint_path(home: &Path) -> PathBuf {
home.join("state").join("heddle-netd.endpoint.json")
}
fn wait_for_discovery(home: &Path, timeout: Duration) -> Value {
let path = endpoint_path(home);
let deadline = Instant::now() + timeout;
loop {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(value) = serde_json::from_str::<Value>(&contents)
{
return value;
}
if Instant::now() >= deadline {
panic!(
"netd discovery file {} did not appear within {:?}",
path.display(),
timeout
);
}
sleep(Duration::from_millis(50));
}
}
fn node_id_of(discovery: &Value) -> String {
discovery
.get("node_id")
.and_then(Value::as_str)
.expect("discovery file advertises a node_id")
.to_string()
}
struct Daemon(Option<Child>);
impl Daemon {
fn stop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for Daemon {
fn drop(&mut self) {
self.stop();
}
}
#[test]
fn discovery_advertises_persisted_node_id() {
let home = TempDir::new().unwrap();
let mut daemon = Daemon(Some(serve(home.path())));
let discovery = wait_for_discovery(home.path(), Duration::from_secs(20));
let node_id = node_id_of(&discovery);
assert_eq!(node_id.len(), 64, "node id must be a 64-hex ed25519 key");
assert!(
node_id.chars().all(|c| c.is_ascii_hexdigit()),
"node id must be lower-hex, got {node_id}"
);
assert_eq!(
discovery.get("version").and_then(Value::as_u64),
Some(1),
"discovery advertises the netd protocol version"
);
assert!(
discovery.get("pid").and_then(Value::as_u64).is_some(),
"discovery records the serving pid"
);
daemon.stop();
}
#[test]
fn node_id_survives_kill_and_restart() {
let home = TempDir::new().unwrap();
let mut first = Daemon(Some(serve(home.path())));
let before = node_id_of(&wait_for_discovery(home.path(), Duration::from_secs(20)));
first.stop();
sleep(Duration::from_millis(200));
let mut second = Daemon(Some(serve(home.path())));
let after = node_id_of(&wait_for_discovery(home.path(), Duration::from_secs(20)));
assert_eq!(
before, after,
"node id must survive a daemon restart (persisted device identity)"
);
second.stop();
}
#[test]
fn second_serve_refuses_while_first_is_live() {
let home = TempDir::new().unwrap();
let mut daemon = Daemon(Some(serve(home.path())));
wait_for_discovery(home.path(), Duration::from_secs(20));
let second = run(home.path(), &["netd", "serve"]);
assert!(
!second.status.success(),
"second serve must fail while the first is alive"
);
let stderr = String::from_utf8_lossy(&second.stderr);
assert!(
stderr.contains("already serving") || stderr.contains("refusing"),
"second serve must explain the single-writer refusal, got: {stderr}"
);
daemon.stop();
}
#[test]
fn status_and_stop_round_trip() {
let home = TempDir::new().unwrap();
let mut daemon = Daemon(Some(serve(home.path())));
let node_id = node_id_of(&wait_for_discovery(home.path(), Duration::from_secs(20)));
let status = run(home.path(), &["netd", "status", "--output", "json"]);
assert!(status.status.success(), "status must succeed");
let status_json: Value =
serde_json::from_slice(&status.stdout).expect("status emits JSON");
assert_eq!(status_json.get("running").and_then(Value::as_bool), Some(true));
assert_eq!(
status_json.get("node_id").and_then(Value::as_str),
Some(node_id.as_str()),
"status reports the advertised node id"
);
let stop = run(home.path(), &["netd", "stop"]);
assert!(stop.status.success(), "stop must succeed");
if let Some(mut child) = daemon.0.take() {
let _ = child.wait();
}
assert!(
!endpoint_path(home.path()).exists(),
"discovery file must be gone after stop"
);
let after = run(home.path(), &["netd", "status"]);
assert!(after.status.success());
assert!(
String::from_utf8_lossy(&after.stdout).contains("not running"),
"status after stop must report not running"
);
}