Files
nash/nash/tests/observe.rs
T

297 lines
11 KiB
Rust
Raw Normal View History

//! End-to-end observation tests: run the real nash binary against a fake
//! Nucleic control-socket sink and assert on the shell-batch events it posts
//! (docs/NASH.md §6.1, §10).
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::process::Command;
use std::sync::mpsc;
use std::time::Duration;
struct Sink {
dir: std::path::PathBuf,
rx: mpsc::Receiver<(Option<String>, serde_json::Value)>,
}
impl Sink {
/// Starts a unix-socket HTTP sink; each posted batch is parsed and sent
/// through the channel along with its Authorization header.
fn start() -> Self {
let dir = std::env::temp_dir().join(format!("nash-test-{}-{:?}", std::process::id(), std::time::Instant::now()));
std::fs::create_dir_all(&dir).unwrap();
let socket_path = dir.join("control.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let tx = tx.clone();
std::thread::spawn(move || {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
// Read until headers + declared body length are complete.
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if let Some(body) = full_body(&buf) {
let auth = header(&buf, "authorization");
if let Ok(json) = serde_json::from_slice(body) {
let _ = tx.send((auth, json));
}
let _ = stream.write_all(
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n",
);
break;
}
}
Err(_) => break,
}
}
});
}
});
Self { dir, rx }
}
fn socket(&self) -> std::path::PathBuf {
self.dir.join("control.sock")
}
/// Collects events from batches until `pred` is satisfied or ~5s elapse.
fn events_until(
&self,
pred: impl Fn(&[serde_json::Value]) -> bool,
) -> Vec<serde_json::Value> {
let mut events = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match self.rx.recv_timeout(Duration::from_millis(250)) {
Ok((auth, batch)) => {
assert_eq!(
auth.as_deref(),
Some("Bearer test-token"),
"batch must carry the bearer token"
);
assert_eq!(batch["type"], "shell-batch");
assert_eq!(batch["source"], "nash");
assert_eq!(batch["sessionId"], "sess-1");
assert!(batch["shellId"].is_string());
events.extend(batch["events"].as_array().cloned().unwrap_or_default());
if pred(&events) {
break;
}
}
Err(_) => {
if pred(&events) {
break;
}
}
}
}
events
}
}
fn full_body(buf: &[u8]) -> Option<&[u8]> {
let headers_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
let len: usize = header(buf, "content-length")?.parse().ok()?;
let body = &buf[headers_end..];
(body.len() >= len).then(|| &body[..len])
}
fn header(buf: &[u8], name: &str) -> Option<String> {
let text = String::from_utf8_lossy(buf);
text.lines()
.take_while(|l| !l.is_empty())
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().to_string())
})
}
fn run_nash(sink: &Sink, script: &str) -> std::process::ExitStatus {
Command::new(env!("CARGO_BIN_EXE_nash"))
.args(["-c", script])
.env("NUCLEIC_SHELL_SOCKET", sink.socket())
.env("NUCLEIC_HOOK_TOKEN", "test-token")
.env("NUCLEIC_SESSION_ID", "sess-1")
.env_remove("NUCLEIC_SHELL_PARENT")
.status()
.unwrap()
}
fn find<'a>(
events: &'a [serde_json::Value],
kind: &str,
pred: impl Fn(&serde_json::Value) -> bool,
) -> Option<&'a serde_json::Value> {
events
.iter()
.find(|e| e["kind"] == kind && pred(e))
}
#[test]
fn exec_events_flow() {
let sink = Sink::start();
let status = run_nash(&sink, "echo observed; false");
assert_eq!(status.code(), Some(1));
let events = sink.events_until(|evs| {
find(evs, "exec", |e| e["argv"][0] == "echo").is_some()
&& find(evs, "exec", |e| e["argv"][0] == "false").is_some()
});
let echo = find(&events, "exec", |e| e["argv"][0] == "echo").expect("echo exec event");
assert_eq!(echo["argv"][1], "observed");
assert_eq!(echo["exitCode"], 0);
assert!(echo["cwd"].is_string());
assert!(echo["durationMs"].is_u64());
let false_ev = find(&events, "exec", |e| e["argv"][0] == "false").expect("false exec event");
assert_eq!(false_ev["exitCode"], 1);
}
#[test]
fn external_command_exit_codes() {
let sink = Sink::start();
let status = run_nash(&sink, "/bin/echo ext; /usr/bin/env true; sh -c 'exit 7'");
assert_eq!(status.code(), Some(7));
let events = sink.events_until(|evs| {
find(evs, "exec", |e| e["argv"][0] == "sh").is_some()
});
let ext = find(&events, "exec", |e| e["argv"][0] == "/bin/echo").expect("external exec event");
assert_eq!(ext["exitCode"], 0);
let sh = find(&events, "exec", |e| e["argv"][0] == "sh").expect("sh exec event");
assert_eq!(sh["exitCode"], 7);
}
#[test]
fn cd_and_export_events() {
let sink = Sink::start();
let status = run_nash(&sink, "cd /tmp && export NASH_E2E=1 OTHER=2 && unset OTHER");
assert_eq!(status.code(), Some(0));
let events = sink.events_until(|evs| {
find(evs, "cd", |_| true).is_some()
&& find(evs, "export", |e| e["op"] == "unset").is_some()
});
let cd = find(&events, "cd", |_| true).expect("cd event");
assert_eq!(cd["to"], "/tmp");
assert!(cd["from"].is_string());
let export = find(&events, "export", |e| e["op"] == "export").expect("export event");
let names: Vec<_> = export["names"].as_array().unwrap().iter().collect();
assert_eq!(names.len(), 2);
assert_eq!(export["names"][0], "NASH_E2E");
assert_eq!(export["names"][1], "OTHER");
// Names only — values must never appear anywhere in the event.
assert!(!serde_json::to_string(export).unwrap().contains('='));
let unset = find(&events, "export", |e| e["op"] == "unset").expect("unset event");
assert_eq!(unset["names"][0], "OTHER");
}
#[test]
fn pipeline_members_each_reported() {
let sink = Sink::start();
let status = run_nash(&sink, "printf 'a\\nb\\n' | wc -l | cat > /dev/null");
assert_eq!(status.code(), Some(0));
let events = sink.events_until(|evs| {
["printf", "wc", "cat"]
.iter()
.all(|c| find(evs, "exec", |e| &e["argv"][0] == c).is_some())
});
for cmd in ["printf", "wc", "cat"] {
let ev = find(&events, "exec", |e| &e["argv"][0] == cmd)
.unwrap_or_else(|| panic!("{cmd} exec event missing"));
assert_eq!(ev["exitCode"], 0, "{cmd} should exit 0");
}
}
#[test]
fn command_not_found_reported() {
let sink = Sink::start();
let status = run_nash(&sink, "definitely-not-a-command-xyz 2>/dev/null; true");
assert_eq!(status.code(), Some(0));
let events = sink.events_until(|evs| {
find(evs, "exec", |e| e["argv"][0] == "definitely-not-a-command-xyz").is_some()
});
let ev = find(&events, "exec", |e| e["argv"][0] == "definitely-not-a-command-xyz")
.expect("not-found exec event");
assert_eq!(ev["exitCode"], 127);
}
#[test]
fn parse_failure_falls_back_to_bash() {
let sink = Sink::start();
let status = Command::new(env!("CARGO_BIN_EXE_nash"))
.args(["-c", "if then fi"])
.env("NUCLEIC_SHELL_SOCKET", sink.socket())
.env("NUCLEIC_HOOK_TOKEN", "test-token")
.env("NUCLEIC_SESSION_ID", "sess-1")
.env("NUCLEIC_REAL_BASH", "/bin/bash")
.env_remove("NUCLEIC_SHELL_PARENT")
.status()
.unwrap();
// bash also rejects this input; what matters is bash's diagnostic + code.
assert_eq!(status.code(), Some(2));
let events = sink.events_until(|evs| find(evs, "fallback", |_| true).is_some());
let fb = find(&events, "fallback", |_| true).expect("fallback event");
assert_eq!(fb["input"], "if then fi");
assert!(fb["reason"].as_str().unwrap().starts_with("parse-error"));
}
#[test]
fn silent_without_config() {
// No NUCLEIC_SHELL_* config: nash must run normally and post nothing.
let out = Command::new(env!("CARGO_BIN_EXE_nash"))
.args(["-c", "echo quiet"])
.env_remove("NUCLEIC_SHELL_SOCKET")
.env_remove("NUCLEIC_SHELL_HOOK_URL")
.env_remove("NUCLEIC_SHELL_SPOOL")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&out.stdout), "quiet\n");
}
#[test]
fn spool_fallback_when_socket_absent() {
let dir = std::env::temp_dir().join(format!("nash-spool-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let status = Command::new(env!("CARGO_BIN_EXE_nash"))
.args(["-c", "echo spooled"])
.env("NUCLEIC_SHELL_SOCKET", "/nonexistent/control.sock")
.env("NUCLEIC_SHELL_SPOOL", &dir)
.env("NUCLEIC_SESSION_ID", "sess-1")
.env_remove("NUCLEIC_SHELL_PARENT")
.status()
.unwrap();
assert_eq!(status.code(), Some(0));
let mut found = false;
for entry in std::fs::read_dir(&dir).expect("spool dir created") {
let content = std::fs::read_to_string(entry.unwrap().path()).unwrap();
for line in content.lines() {
let batch: serde_json::Value = serde_json::from_str(line).unwrap();
if batch["events"]
.as_array()
.unwrap()
.iter()
.any(|e| e["kind"] == "exec" && e["argv"][0] == "echo")
{
found = true;
}
}
}
assert!(found, "exec event spooled to disk");
let _ = std::fs::remove_dir_all(&dir);
}