use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const SPAWN_GRACE: Duration = Duration::from_secs(8);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
fn zshrs_daemon_binary() -> PathBuf {
if let Ok(p) = std::env::var("CARGO_BIN_EXE_zshrs-daemon") {
return PathBuf::from(p);
}
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let path = manifest.join("target").join("debug").join("zshrs-daemon");
static BUILT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if !path.exists() {
BUILT.get_or_init(|| {
let status = Command::new(env!("CARGO"))
.args(["build", "-p", "zshrs-daemon", "--bin", "zshrs-daemon"])
.current_dir(&manifest)
.stdin(Stdio::null())
.status()
.expect("cargo build -p zshrs-daemon");
assert!(status.success(), "cargo build failed for zshrs-daemon");
});
}
path
}
fn pick_free_port() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let port = l.local_addr().unwrap().port();
drop(l);
port
}
struct DaemonHttp {
_zshrs_home: tempfile::TempDir,
port: u16,
child: Option<Child>,
}
impl DaemonHttp {
fn spawn(token: Option<&str>) -> Self {
Self::spawn_with_extra_toml(token, "")
}
fn spawn_with_extra_toml(token: Option<&str>, extra_toml: &str) -> Self {
let zshrs_home = tempfile::TempDir::new().expect("zshrs home tempdir");
let port = pick_free_port();
std::fs::create_dir_all(zshrs_home.path()).expect("mk zshrs home");
let mut f = std::fs::File::create(zshrs_home.path().join("daemon.toml"))
.expect("create toml");
write!(f, "[http]\nlisten = \"127.0.0.1:{port}\"\n").unwrap();
if let Some(tok) = token {
write!(f, "\n[http.tokens]\ntest-tok = \"{tok}\"\n").unwrap();
}
if !extra_toml.is_empty() {
write!(f, "\n{extra_toml}\n").unwrap();
}
drop(f);
let child = Command::new(zshrs_daemon_binary())
.env("ZSHRS_HOME", zshrs_home.path())
.env("ZSHRS_QUIET_FIRST_RUN", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("daemon spawn");
let me = Self {
_zshrs_home: zshrs_home,
port,
child: Some(child),
};
me.wait_ready();
me
}
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{}", self.port, path)
}
fn wait_ready(&self) {
let start = Instant::now();
while start.elapsed() < SPAWN_GRACE {
if let Ok(resp) = ureq::get(&self.url("/health")).timeout(Duration::from_millis(200)).call() {
if resp.status() == 200 {
return;
}
}
std::thread::sleep(POLL_INTERVAL);
}
panic!(
"daemon http listener did not come up at 127.0.0.1:{} within {SPAWN_GRACE:?}",
self.port
);
}
}
impl Drop for DaemonHttp {
fn drop(&mut self) {
if let Some(mut c) = self.child.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
#[test]
fn health_endpoint_returns_version_and_uptime() {
let d = DaemonHttp::spawn(None);
let resp = ureq::get(&d.url("/health")).call().expect("GET /health");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.into_json().expect("json");
assert_eq!(body["ok"], serde_json::json!(true));
assert!(
body["version"].as_str().is_some(),
"version field missing: {body}"
);
assert!(
body["uptime_ms"].as_u64().is_some(),
"uptime_ms field missing: {body}"
);
}
#[test]
fn ops_endpoint_lists_known_ops() {
let d = DaemonHttp::spawn(None);
let resp = ureq::get(&d.url("/ops")).call().expect("GET /ops");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.into_json().expect("json");
let ops = body["ops"]
.as_array()
.expect("ops array")
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>();
for must in ["ping", "info", "recorder_ingest", "config_get"] {
assert!(ops.contains(&must), "missing op {must:?} in /ops list");
}
}
#[test]
fn op_ping_returns_pong() {
let d = DaemonHttp::spawn(None);
let resp = ureq::post(&d.url("/op/ping"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("POST /op/ping");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.into_json().expect("json");
assert_eq!(body["ok"], serde_json::json!(true));
assert_eq!(body["pong"], serde_json::json!(true));
}
#[test]
fn unknown_op_returns_404() {
let d = DaemonHttp::spawn(None);
let resp = ureq::post(&d.url("/op/this_op_does_not_exist"))
.set("Content-Type", "application/json")
.send_string("{}");
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => panic!("unexpected ureq error: {e}"),
};
assert_eq!(resp.status(), 404);
let body: serde_json::Value = resp.into_json().expect("json");
assert_eq!(body["ok"], serde_json::json!(false));
assert_eq!(body["code"], serde_json::json!("unknown_op"));
}
#[test]
fn auth_required_when_tokens_configured() {
let d = DaemonHttp::spawn(Some("test-secret-456"));
let r = ureq::post(&d.url("/op/ping"))
.set("Content-Type", "application/json")
.send_string("{}");
let r = match r {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => panic!("unexpected ureq error: {e}"),
};
assert_eq!(r.status(), 401, "expected 401 without bearer token");
let r = ureq::post(&d.url("/op/ping"))
.set("Authorization", "Bearer wrong")
.set("Content-Type", "application/json")
.send_string("{}");
let r = match r {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => panic!("unexpected ureq error: {e}"),
};
assert_eq!(r.status(), 401, "expected 401 with wrong bearer token");
let r = ureq::post(&d.url("/op/ping"))
.set("Authorization", "Bearer test-secret-456")
.set("Content-Type", "application/json")
.send_string("{}")
.expect("authorized POST should succeed");
assert_eq!(r.status(), 200);
let body: serde_json::Value = r.into_json().expect("json");
assert_eq!(body["pong"], serde_json::json!(true));
}
#[test]
fn cache_round_trip() {
let d = DaemonHttp::spawn(None);
let r = ureq::post(&d.url("/op/cache_put"))
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"t","key":"k1","value":"hello"}"#)
.expect("cache_put");
assert_eq!(r.status(), 200);
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["bytes"], serde_json::json!(5));
let r = ureq::post(&d.url("/op/cache_get"))
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"t","key":"k1"}"#)
.expect("cache_get");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["value"], serde_json::json!("hello"));
let r = ureq::post(&d.url("/op/cache_list"))
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"t"}"#)
.expect("cache_list");
let body: serde_json::Value = r.into_json().unwrap();
let keys = body["keys"].as_array().unwrap();
assert!(keys.iter().any(|v| v.as_str() == Some("k1")));
let r = ureq::post(&d.url("/op/cache_del"))
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"t","key":"k1"}"#)
.expect("cache_del");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["deleted"], serde_json::json!(true));
let r = ureq::post(&d.url("/op/cache_get"))
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"t","key":"k1"}"#);
let r = match r {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => panic!("ureq error: {e}"),
};
assert_eq!(r.status(), 404);
}
#[test]
fn lock_acquire_release_roundtrip() {
let d = DaemonHttp::spawn(None);
let pid = std::process::id();
let r = ureq::post(&d.url("/op/lock_try_acquire"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"name":"L","pid":{pid}}}"#))
.expect("lock_try_acquire");
let body: serde_json::Value = r.into_json().unwrap();
let token = body["token"].as_str().expect("token").to_string();
let r = ureq::post(&d.url("/op/lock_try_acquire"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"name":"L","pid":{pid}}}"#));
let r = match r {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => panic!("ureq error: {e}"),
};
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["ok"], serde_json::json!(false));
assert_eq!(body["code"], serde_json::json!("busy"));
let r = ureq::post(&d.url("/op/lock_release"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"name":"L","token":"{token}"}}"#))
.expect("lock_release");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["released"], serde_json::json!(true));
let r = ureq::post(&d.url("/op/lock_try_acquire"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"name":"L","pid":{pid}}}"#))
.expect("re-acquire");
let body: serde_json::Value = r.into_json().unwrap();
assert!(body["token"].as_str().is_some());
}
#[test]
fn artifact_round_trip() {
let d = DaemonHttp::spawn(None);
let r = ureq::post(&d.url("/op/artifact_put"))
.set("Content-Type", "application/json")
.send_string(r#"{"name":"art-x","value":"some bytes"}"#)
.expect("artifact_put");
let body: serde_json::Value = r.into_json().unwrap();
let digest = body["digest"].as_str().expect("digest").to_string();
assert_eq!(digest.len(), 64);
let r = ureq::post(&d.url("/op/artifact_get"))
.set("Content-Type", "application/json")
.send_string(r#"{"name":"art-x"}"#)
.expect("artifact_get");
let body: serde_json::Value = r.into_json().unwrap();
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(body["value_base64"].as_str().unwrap())
.expect("base64 decode");
assert_eq!(&bytes, b"some bytes");
let r = ureq::post(&d.url("/op/artifact_get_by_digest"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"digest":"{digest}"}}"#))
.expect("artifact_get_by_digest");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["digest"], serde_json::json!(digest));
}
#[test]
fn snapshot_save_list_diff() {
let d = DaemonHttp::spawn(None);
let r = ureq::post(&d.url("/op/snapshot_save"))
.set("Content-Type", "application/json")
.send_string(r#"{"tag":"sn-base"}"#)
.expect("snapshot_save");
let body: serde_json::Value = r.into_json().unwrap();
assert!(body["bytes"].as_u64().unwrap() > 0);
let r = ureq::post(&d.url("/op/snapshot_list"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("snapshot_list");
let body: serde_json::Value = r.into_json().unwrap();
let tags: Vec<&str> = body["snapshots"]
.as_array()
.unwrap()
.iter()
.filter_map(|s| s["tag"].as_str())
.collect();
assert!(tags.contains(&"sn-base"));
let r = ureq::post(&d.url("/op/snapshot_diff"))
.set("Content-Type", "application/json")
.send_string(r#"{"a":"sn-base","b":"sn-base"}"#)
.expect("snapshot_diff");
let body: serde_json::Value = r.into_json().unwrap();
assert!(body["added"].as_array().unwrap().is_empty());
assert!(body["removed"].as_array().unwrap().is_empty());
assert!(body["changed"].as_array().unwrap().is_empty());
}
#[test]
fn health_remains_open_when_tokens_configured() {
let d = DaemonHttp::spawn(Some("any-tok"));
let resp = ureq::get(&d.url("/health")).call().expect("GET /health");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.into_json().expect("json");
assert_eq!(body["ok"], serde_json::json!(true));
}
#[test]
fn definitions_federation_keeps_per_shell_rows_distinct() {
let d = DaemonHttp::spawn(None);
ureq::post(&d.url("/op/definitions_emit"))
.set("Content-Type", "application/json")
.send_string(r#"{"shell_id":"bash","kind":"alias","name":"ll","value":"ls -al"}"#)
.expect("emit bash ll");
ureq::post(&d.url("/op/definitions_emit"))
.set("Content-Type", "application/json")
.send_string(r#"{"shell_id":"zshrs","kind":"alias","name":"ll","value":"ls -alh"}"#)
.expect("emit zshrs ll");
ureq::post(&d.url("/op/definitions_emit"))
.set("Content-Type", "application/json")
.send_string(r#"{"shell_id":"bash","kind":"env","name":"PAGER","value":"less"}"#)
.expect("emit bash PAGER");
let r = ureq::post(&d.url("/op/definitions_query"))
.set("Content-Type", "application/json")
.send_string(r#"{"kind":"alias"}"#)
.expect("query all");
let body: serde_json::Value = r.into_json().unwrap();
let recs = body["records"].as_array().unwrap();
assert_eq!(recs.len(), 2, "expected both shells' ll rows: {body}");
let shells: std::collections::HashSet<&str> = recs
.iter()
.filter_map(|r| r["shell_id"].as_str())
.collect();
assert!(shells.contains("bash"), "missing bash row: {body}");
assert!(shells.contains("zshrs"), "missing zshrs row: {body}");
let r = ureq::post(&d.url("/op/definitions_query"))
.set("Content-Type", "application/json")
.send_string(r#"{"kind":"alias","shell_id":"bash"}"#)
.expect("query bash");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["count"], serde_json::json!(1));
assert_eq!(body["records"][0]["shell_id"], serde_json::json!("bash"));
assert_eq!(body["records"][0]["value"], serde_json::json!("ls -al"));
let r = ureq::post(&d.url("/op/definitions_diff"))
.set("Content-Type", "application/json")
.send_string(r#"{"shell_a":"bash","shell_b":"zshrs"}"#)
.expect("diff");
let body: serde_json::Value = r.into_json().unwrap();
let changed = body["changed"].as_array().unwrap();
assert!(
changed.iter().any(|c| c["name"] == "ll" && c["from"] == "ls -al" && c["to"] == "ls -alh"),
"expected ll changed entry: {body}"
);
let removed = body["removed"].as_array().unwrap();
assert!(
removed.iter().any(|r| r["name"] == "PAGER"),
"expected PAGER removed (only in bash): {body}"
);
}
#[test]
fn definitions_emit_rejects_missing_shell_id() {
let d = DaemonHttp::spawn(None);
let resp = ureq::post(&d.url("/op/definitions_emit"))
.set("Content-Type", "application/json")
.send_string(r#"{"kind":"alias","name":"ll","value":"ls"}"#);
let err = resp.expect_err("expected 400 missing shell_id");
let status = match err {
ureq::Error::Status(s, _) => s,
ureq::Error::Transport(t) => panic!("transport error: {t}"),
};
assert_eq!(status, 400);
}
#[test]
fn definitions_emit_rejects_unknown_kind() {
let d = DaemonHttp::spawn(None);
let resp = ureq::post(&d.url("/op/definitions_emit"))
.set("Content-Type", "application/json")
.send_string(r#"{"shell_id":"bash","kind":"banana","name":"x"}"#);
let err = resp.expect_err("expected 404 unknown kind");
let status = match err {
ureq::Error::Status(s, _) => s,
ureq::Error::Transport(t) => panic!("transport error: {t}"),
};
assert_eq!(status, 404);
}
#[test]
fn definitions_subscribe_unsubscribe_round_trip() {
let d = DaemonHttp::spawn(None);
let r = ureq::post(&d.url("/op/definitions_subscribe"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("subscribe");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["subscribed"], serde_json::json!(true));
assert_eq!(body["was_subscribed"], serde_json::json!(false));
let r = ureq::post(&d.url("/op/definitions_unsubscribe"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("unsubscribe");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["subscribed"], serde_json::json!(false));
}
#[test]
fn watch_subscribe_returns_id_and_lists() {
let d = DaemonHttp::spawn(None);
let tmp = tempfile::TempDir::new().expect("tempdir for watch");
let path = tmp.path().to_str().unwrap().to_string();
let r1 = ureq::post(&d.url("/op/watch_subscribe"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"path":"{path}"}}"#))
.expect("watch_subscribe 1");
let b1: serde_json::Value = r1.into_json().unwrap();
let id1 = b1["watch_id"].as_u64().expect("watch_id u64");
assert!(id1 > 0);
let r2 = ureq::post(&d.url("/op/watch_subscribe"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"path":"{path}","recursive":true}}"#))
.expect("watch_subscribe 2");
let b2: serde_json::Value = r2.into_json().unwrap();
let id2 = b2["watch_id"].as_u64().expect("watch_id u64");
assert_ne!(id1, id2, "ids must be distinct");
let r = ureq::post(&d.url("/op/watch_list"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("watch_list");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["count"], serde_json::json!(2));
let subs = body["subscriptions"].as_array().unwrap();
assert!(subs.iter().all(|s| s["ref_count"] == serde_json::json!(2)));
let r = ureq::post(&d.url("/op/watch_unsubscribe"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"watch_id":{id1}}}"#))
.expect("watch_unsubscribe 1");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["removed"], serde_json::json!(true));
let r = ureq::post(&d.url("/op/watch_list"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("watch_list 2");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["count"], serde_json::json!(1));
assert_eq!(body["subscriptions"][0]["ref_count"], serde_json::json!(1));
let _ = ureq::post(&d.url("/op/watch_unsubscribe"))
.set("Content-Type", "application/json")
.send_string(&format!(r#"{{"watch_id":{id2}}}"#))
.expect("watch_unsubscribe 2");
let r = ureq::post(&d.url("/op/watch_list"))
.set("Content-Type", "application/json")
.send_string("{}")
.expect("watch_list 3");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["count"], serde_json::json!(0));
}
#[test]
fn watch_unsubscribe_unknown_id_is_idempotent() {
let d = DaemonHttp::spawn(None);
let r = ureq::post(&d.url("/op/watch_unsubscribe"))
.set("Content-Type", "application/json")
.send_string(r#"{"watch_id":999999}"#)
.expect("watch_unsubscribe missing id");
let body: serde_json::Value = r.into_json().unwrap();
assert_eq!(body["removed"], serde_json::json!(false));
}
fn status_of(err: ureq::Error) -> u16 {
match err {
ureq::Error::Status(s, _) => s,
ureq::Error::Transport(t) => panic!("transport error: {t}"),
}
}
#[test]
fn legacy_unscoped_token_grants_full_access() {
let d = DaemonHttp::spawn(Some("legacy-secret"));
for op in ["info", "cache_stats", "definitions_kinds", "snapshot_list"] {
let r = ureq::post(&d.url(&format!("/op/{op}")))
.set("Authorization", "Bearer legacy-secret")
.set("Content-Type", "application/json")
.send_string("{}")
.unwrap_or_else(|e| panic!("{op}: {e}"));
assert_eq!(r.status(), 200, "{op}");
}
}
#[test]
fn scoped_token_allows_listed_scope_only() {
let extra = r#"
[http.tokens.cache-only]
token = "cache-secret"
scopes = ["cache.*"]
"#;
let d = DaemonHttp::spawn_with_extra_toml(None, extra);
let r = ureq::post(&d.url("/op/cache_stats"))
.set("Authorization", "Bearer cache-secret")
.set("Content-Type", "application/json")
.send_string("{}")
.expect("cache_stats");
assert_eq!(r.status(), 200);
let err = ureq::post(&d.url("/op/snapshot_save"))
.set("Authorization", "Bearer cache-secret")
.set("Content-Type", "application/json")
.send_string(r#"{"tag":"x"}"#)
.expect_err("snapshot_save must 403");
assert_eq!(status_of(err), 403);
}
#[test]
fn scope_denied_response_carries_required_and_granted() {
let extra = r#"
[http.tokens.read-only]
token = "ro-secret"
scopes = ["*.read"]
"#;
let d = DaemonHttp::spawn_with_extra_toml(None, extra);
let err = ureq::post(&d.url("/op/cache_put"))
.set("Authorization", "Bearer ro-secret")
.set("Content-Type", "application/json")
.send_string(r#"{"ns":"a","key":"b","value":"c"}"#)
.expect_err("cache_put must 403");
let (status, resp) = match err {
ureq::Error::Status(s, r) => (s, r),
ureq::Error::Transport(t) => panic!("transport error: {t}"),
};
assert_eq!(status, 403);
let body: serde_json::Value = resp.into_json().unwrap();
assert_eq!(body["code"], serde_json::json!("scope_denied"));
assert_eq!(body["required_scope"], serde_json::json!("cache.write"));
let granted = body["granted_scopes"].as_array().unwrap();
assert!(granted.iter().any(|v| v == "*.read"));
}
#[test]
fn verb_wildcard_grants_read_across_areas() {
let extra = r#"
[http.tokens.dashboard]
token = "dash-secret"
scopes = ["*.read"]
"#;
let d = DaemonHttp::spawn_with_extra_toml(None, extra);
for op in ["cache_stats", "definitions_kinds", "snapshot_list", "lock_list"] {
let r = ureq::post(&d.url(&format!("/op/{op}")))
.set("Authorization", "Bearer dash-secret")
.set("Content-Type", "application/json")
.send_string("{}")
.unwrap_or_else(|e| panic!("{op}: {e}"));
assert_eq!(r.status(), 200, "{op} (scope = {})", auth_scope(op));
}
}
#[test]
fn unknown_op_falls_through_to_meta_admin_scope() {
let extra = r#"
[http.tokens.cache-only]
token = "co-secret"
scopes = ["cache.read"]
"#;
let d = DaemonHttp::spawn_with_extra_toml(None, extra);
let err = ureq::post(&d.url("/op/zzz_definitely_not_a_real_op"))
.set("Authorization", "Bearer co-secret")
.set("Content-Type", "application/json")
.send_string("{}")
.expect_err("must reject");
let (status, resp) = match err {
ureq::Error::Status(s, r) => (s, r),
ureq::Error::Transport(t) => panic!("transport error: {t}"),
};
assert_eq!(status, 403, "scope check fires before unknown-op 404");
let body: serde_json::Value = resp.into_json().unwrap();
assert_eq!(body["required_scope"], serde_json::json!("meta.admin"));
}
fn auth_scope(op: &str) -> &'static str {
zsh::daemon::auth::op_scope(op)
}