//! The services an operation acts on, and the keys they are reached with, through the `wai` binary: //! `wai target add|use|list|remove`, `wai auth login|status|logout`, the shared `--target` on the //! fleet's operations and on every operation of a service's API (`wai room show --target `), //! and `wai mcp --target` with `--allow-write`. The service is a stand-in in this test that records //! every request it is sent and answers as it is told; nothing here depends on any other program. //! //! What is held, each against the fault it exists for: //! - a key never reaches `wai.json`, a target's list, standard output, standard error, a plan or an //! error, even where the service repeats it in its answer; //! - the key sent is `WAI_TOKEN`'s ahead of the kept one, and only over https:// or to this machine; //! - a read-only target refuses every operation that would change it, and nothing is sent; //! - an unknown target is exit 5 (`target-not-found`) in the envelope; //! - a change to a service waits for `--yes`, and a dry run names the request and sends nothing. #![cfg(feature = "quickstart")] use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::mpsc; use std::time::Duration; // A program a test runs is written by a process of its own (ETXTBSY: support/program.rs). #[path = "support/program.rs"] mod program; const KEY: &str = "k_0123456789abcdef-a-key-for-the-tests"; const TOKEN: &str = "k_fedcba9876543210-the-environments"; fn scratch(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("wai-cli-target-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(d.join("project")).unwrap(); d } /// A run's environment: the project's directory, and a configuration directory of the test's own. struct Env { root: PathBuf, token: Option<&'static str>, } impl Env { fn new(name: &str) -> Env { Env { root: scratch(name), token: None } } fn project(&self) -> PathBuf { self.root.join("project") } fn config(&self) -> PathBuf { self.root.join("config") } fn cmd(&self, args: &[&str]) -> Command { let mut c = Command::new(env!("CARGO_BIN_EXE_wai")); c.current_dir(self.project()).env("WAI_CONFIG_DIR", self.config()).env_remove("WAI_TOKEN").args(args); if let Some(t) = self.token { c.env("WAI_TOKEN", t); } c } /// `wai` as an agent runs it: no terminal, `--json`. fn json(&self, args: &[&str]) -> (Option, Value, Output) { let o = self.cmd(args).arg("--json").stdin(Stdio::null()).output().expect("wai runs"); let v = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: {e}: {}", text(&o))); (o.status.code(), v, o) } fn text(&self, args: &[&str]) -> Output { self.cmd(args).stdin(Stdio::null()).output().expect("wai runs") } /// `wai` given `input` on its standard input. fn with_input(&self, args: &[&str], input: &str) -> Output { let mut c = self.cmd(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().expect("wai runs"); // A run that refuses before it reads may have closed its input already. let _ = c.stdin.take().unwrap().write_all(input.as_bytes()); c.wait_with_output().unwrap() } /// Every byte this test's runs left in the project and the configuration directory. fn every_file(&self) -> String { fn walk(p: &Path, out: &mut String) { for e in std::fs::read_dir(p).into_iter().flatten().flatten() { let q = e.path(); if q.is_dir() { walk(&q, out); } else { out.push_str(&format!("{}\n{}\n", q.display(), String::from_utf8_lossy(&std::fs::read(&q).unwrap_or_default()))); } } } let mut s = String::new(); walk(&self.root, &mut s); s } } fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// One request a stand-in service was sent. #[derive(Debug)] struct Seen { line: String, headers: Vec<(String, String)>, body: String, } impl Seen { fn header(&self, name: &str) -> Option<&str> { self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) } } /// A service on a loopback port answering each request with the next of `answers`, and passing on /// each request it was sent. fn service(answers: Vec<(u16, Value)>) -> (String, mpsc::Receiver) { let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = format!("http://{}", l.local_addr().unwrap()); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for (status, body) in answers { let Ok((mut s, _)) = l.accept() else { return }; let mut buf = Vec::new(); let mut chunk = [0u8; 65536]; loop { let n = s.read(&mut chunk).unwrap_or(0); buf.extend_from_slice(&chunk[..n]); if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") { let head = String::from_utf8_lossy(&buf[..at]).to_string(); let len = head.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").and_then(|v| v.parse::().ok())).unwrap_or(0); while buf.len() < at + 4 + len { let n = s.read(&mut chunk).unwrap_or(0); if n == 0 { break; } buf.extend_from_slice(&chunk[..n]); } let mut lines = head.lines(); let line = lines.next().unwrap_or("").to_string(); let headers = lines.filter_map(|l| l.split_once(':').map(|(n, v)| (n.trim().to_string(), v.trim().to_string()))).collect(); let _ = tx.send(Seen { line, headers, body: String::from_utf8_lossy(&buf[at + 4..]).to_string() }); break; } if n == 0 { break; } } let b = body.to_string(); let _ = s.write_all(format!("HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{b}", b.len()).as_bytes()); } }); (addr, rx) } fn got(rx: &mpsc::Receiver) -> Seen { rx.recv_timeout(Duration::from_secs(20)).expect("the service was sent a request") } fn nothing_sent(rx: &mpsc::Receiver) { assert!(rx.recv_timeout(Duration::from_millis(300)).is_err(), "the service was sent a request"); } fn minimal_project(e: &Env) { std::fs::write(e.project().join("wai.json"), "{\n \"$schema\": \"https://wai.transaction.science/schema/wai.json/1\",\n \"wai\": 1,\n \"name\": \"p\"\n}\n").unwrap(); } #[test] fn a_kept_key_is_sent_to_its_origin_and_never_written_printed_or_logged() { let e = Env::new("key"); minimal_project(&e); let (url, rx) = service(vec![ (200, json!({"room": "abc"})), (401, json!({"error": format!("the key {KEY} is not known here")})), (200, json!({"room": "abc"})), (200, json!({"room": "abc"})), (200, json!({"room": "abc"})), ]); let base = format!("{url}/room-api"); // A target, kept in the project's wai.json, with no key. let (code, v, _) = e.json(&["target", "add", "staging", "--url", &base]); assert_eq!(code, Some(0), "{v}"); assert_eq!((v["data"]["kept_in"].as_str(), v["data"]["key_kept"].as_bool()), (Some("wai.json"), Some(false)), "{v}"); let project: Value = serde_json::from_str(&std::fs::read_to_string(e.project().join("wai.json")).unwrap()).unwrap(); assert_eq!(project["targets"], json!({"staging": {"url": base}}), "{project}"); assert_eq!(project["name"], "p", "the rest of the file as it was"); // A key, from standard input, kept for the origin in a file only its owner reads. let o = e.with_input(&["auth", "login", "--url", &base, "--store", "file"], &format!("{KEY}\n")); assert!(o.status.success(), "{}", text(&o)); assert!(!text(&o).contains(KEY), "the key was printed: {}", text(&o)); let secret = std::fs::read_dir(e.config().join("credentials")).unwrap().flatten().next().unwrap().path(); assert_eq!(std::fs::read_to_string(&secret).unwrap().trim(), KEY); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(std::fs::metadata(&secret).unwrap().permissions().mode() & 0o777, 0o600, "only its owner reads it"); assert_eq!(std::fs::metadata(e.config()).unwrap().permissions().mode() & 0o077, 0, "nor its directory"); } // Replacing it waits for --yes, asked before any key is read. let o = e.with_input(&["auth", "login", "--url", &base, "--store", "file"], "k_another\n"); assert_eq!(o.status.code(), Some(4), "{}", text(&o)); assert_eq!(std::fs::read_to_string(&secret).unwrap().trim(), KEY, "nothing changed"); let (code, v, o) = e.json(&["auth", "status"]); assert_eq!(code, Some(0), "{v}"); assert_eq!((v["data"]["credentials"][0]["origin"].as_str(), v["data"]["credentials"][0]["store"].as_str(), v["data"]["wai_token"].as_bool()), (Some(url.as_str()), Some("file"), Some(false)), "{v}"); assert!(!text(&o).contains(KEY)); // Sent to the target, as a bearer, on its path; the envelope says where it came from, never what. let (code, v, o) = e.json(&["room", "show", "--target", "staging"]); assert_eq!(code, Some(0), "{v}"); let s = got(&rx); assert_eq!(s.line, "GET /room-api/room HTTP/1.0"); assert_eq!(s.header("authorization"), Some(format!("Bearer {KEY}").as_str()), "{s:?}"); assert_eq!((v["op"].as_str(), v["data"]["status"].as_u64(), v["data"]["body"]["room"].as_str(), v["data"]["key"].as_str()), (Some("room.show"), Some(200), Some("abc"), Some("kept")), "{v}"); assert!(!text(&o).contains(KEY), "{}", text(&o)); // A service that repeats the key in its refusal: exit 3, and the key is not in what is shown. let (code, v, o) = e.json(&["room", "show", "--target", "staging"]); let _ = got(&rx); assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("permission-denied")), "{v}"); assert!(v["error"]["message"].as_str().unwrap().contains(""), "{v}"); assert!(!text(&o).contains(KEY), "the key was shown: {}", text(&o)); let o = e.text(&["room", "show", "--target", "staging"]); let _ = got(&rx); assert!(o.status.success() && !text(&o).contains(KEY), "{}", text(&o)); // WAI_TOKEN is sent ahead of the kept key. let mut with_token = Env { root: e.root.clone(), token: Some(TOKEN) }; let (code, v, o) = with_token.json(&["room", "show", "--target", "staging"]); assert_eq!(code, Some(0), "{v}"); assert_eq!(got(&rx).header("authorization"), Some(format!("Bearer {TOKEN}").as_str())); assert_eq!(v["data"]["key"], "WAI_TOKEN"); assert!(!text(&o).contains(TOKEN) && !text(&o).contains(KEY), "{}", text(&o)); // ... and over http:// to another machine, neither goes: nothing is sent. let (code, v, _) = with_token.json(&["room", "show", "--target", "http://192.0.2.1:9"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("key-over-plaintext")), "{v}"); with_token.token = None; // No key is kept for an http:// address of another machine. let (code, v, _) = e.json(&["auth", "login", "--url", "http://192.0.2.1:9"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("key-over-plaintext")), "{v}"); // A dry run of a change names its request, and nothing is sent or shown of the key. let (code, v, o) = e.json(&["room", "grant", "--target", "staging", "--body", r#"{"principal":"00","role":"viewer"}"#, "--dry-run"]); assert_eq!((code, v["dry_run"].as_bool()), (Some(0), Some(true)), "{v}"); assert_eq!((v["plan"][0]["request"].as_str(), v["plan"][0]["target"].as_str()), (Some("POST /grant"), Some(base.as_str())), "{v}"); assert!(!text(&o).contains(KEY)); nothing_sent(&rx); // Nowhere in the project or the configuration but the key's own file. let files = e.every_file(); assert_eq!(files.matches(KEY).count(), 1, "the key is in its own file alone:\n{files}"); assert!(!std::fs::read_to_string(e.project().join("wai.json")).unwrap().contains(KEY), "wai.json holds no key"); // Removed, it is gone, and nothing is sent with the next request. assert_eq!(e.json(&["auth", "logout", "--url", &url]).0, Some(4), "removing a key waits for --yes"); let (code, v, _) = e.json(&["auth", "logout", "--url", &url, "--yes"]); assert_eq!(code, Some(0), "{v}"); assert!(!secret.exists(), "its file is removed"); let (code, _, _) = e.json(&["room", "show", "--target", "staging"]); assert_eq!(code, Some(0)); assert_eq!(got(&rx).header("authorization"), None, "no key is kept for it now"); assert_eq!(e.json(&["auth", "logout", "--url", &url, "--yes"]).0, Some(5), "none to remove"); assert!(!e.every_file().contains(KEY), "the key is gone"); let _ = std::fs::remove_dir_all(&e.root); } #[test] fn a_project_file_never_holds_a_key() { let e = Env::new("project-key"); std::fs::write(e.project().join("wai.json"), r#"{"targets":{"staging":{"url":"https://h","token":"k_hidden"}}}"#).unwrap(); let (code, v, o) = e.json(&["target", "list"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("project-malformed")), "{v}"); assert!(v["error"]["message"].as_str().unwrap().contains("a key is never kept"), "{v}"); assert!(!text(&o).contains("k_hidden"), "{}", text(&o)); let (code, _, _) = e.json(&["room", "show", "--target", "staging"]); assert_eq!(code, Some(2), "a file that holds a key is read for no target"); // An address that names a user is refused, from the command line too. std::fs::write(e.project().join("wai.json"), "{}").unwrap(); for url in ["https://me:k_pw@h", "ftp://me:k_pw@h", "https://me:k_pw@h:99999/x?y"] { let (code, v, o) = e.json(&["target", "add", "s", "--url", url]); assert_eq!(code, Some(2), "{v}"); assert!(!text(&o).contains("k_pw"), "a user's password in an address is not repeated: {}", text(&o)); let (code, _, o) = e.json(&["room", "show", "--target", url]); assert!(code == Some(2) && !text(&o).contains("k_pw"), "{}", text(&o)); } assert_eq!(std::fs::read_to_string(e.project().join("wai.json")).unwrap(), "{}", "nothing written"); let _ = std::fs::remove_dir_all(&e.root); } #[test] fn a_read_only_target_refuses_every_change_and_sends_nothing() { let e = Env::new("read-only"); let (url, rx) = service(vec![(200, json!({"ok": true})), (200, json!({"enrolments": []}))]); let (code, v, _) = e.json(&["target", "add", "watch", "--url", &url, "--read-only"]); assert_eq!((code, v["data"]["read_only"].as_bool()), (Some(0), Some(true)), "{v}"); assert!(v["data"]["kept_in"].as_str().unwrap().ends_with("targets.json"), "no project here: the person's own list: {v}"); for args in [ &["room", "grant", "--target", "watch", "--body", r#"{"principal":"00","role":"viewer"}"#, "--yes"][..], &["room", "grant", "--target", "watch", "--dry-run"], &["message", "create", "--target", "watch", "--body", "{}", "--yes"], &["report", "create", "--target", "watch", "--start", "0", "--end", "1", "--yes"], ] { let (code, v, _) = e.json(args); assert_eq!((code, v["error"]["code"].as_str(), v["exit"].as_u64()), (Some(3), Some("target-read-only"), Some(3)), "{args:?}: {v}"); } #[cfg(feature = "fleet")] { std::fs::write(e.project().join("org.key"), "07".repeat(32)).unwrap(); let (code, v, _) = e.json(&["enrolment", "create", &"11".repeat(32), "--role", "relay", "--key", "org.key", "--target", "watch", "--yes"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(3), Some("target-read-only")), "{v}"); assert!(!e.project().join(format!("{}.wfe", "11".repeat(8))).exists(), "nothing written either"); } nothing_sent(&rx); // Reading it is what it is for. let (code, v, _) = e.json(&["room", "show", "--target", "watch"]); assert_eq!(code, Some(0), "{v}"); assert_eq!(got(&rx).line, "GET /room HTTP/1.0"); let (code, v, _) = e.json(&["enrolment", "list", "--target", "watch"]); assert_eq!(code, Some(0), "{v}"); assert_eq!(got(&rx).line, "GET /v1/fleet/enrolments HTTP/1.0"); // A server for it offers its reads alone, --allow-write or not. for extra in [&[][..], &["--allow-write"]] { let mut args = vec!["mcp", "--target", "watch", "--dry-run"]; args.extend_from_slice(extra); let (code, v, _) = e.json(&args); assert_eq!(code, Some(0), "{v}"); let tools: Vec<&str> = v["data"]["tools"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); assert!(tools.contains(&"room_show") && !tools.contains(&"room_grant") && !tools.contains(&"message_create"), "{tools:?}"); } let _ = std::fs::remove_dir_all(&e.root); } #[test] fn an_unknown_target_is_not_found_exit_5_in_the_envelope() { let e = Env::new("unknown"); for args in [&["room", "show", "--target", "nowhere"][..], &["enrolment", "list", "--target", "nowhere"], &["report", "create", "--target", "nowhere", "--start", "0", "--end", "1", "--yes"], &["target", "use", "nowhere"], &["target", "remove", "nowhere", "--yes"], &["mcp", "--target", "nowhere"]] { let (code, v, _) = e.json(args); assert_eq!(code, Some(5), "{args:?}: {v}"); assert_eq!((v["ok"].as_bool(), v["exit"].as_u64(), v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(false), Some(5), Some("target-not-found"), Some(false)), "{args:?}: {v}"); assert!(v["error"]["hint"].as_str().unwrap().contains("wai target add"), "{v}"); } // None named and none in use: usage. A name that is neither a name nor an address, and a // target named local, too. for args in [&["room", "show"][..], &["room", "show", "--target", "Not A Name"], &["room", "show", "--target", "ftp://h"], &["target", "add", "local", "--url", "http://127.0.0.1:9"]] { let (code, v, _) = e.json(args); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("usage")), "{args:?}: {v}"); } // local is the stack wai dev runs here, and none is recorded: not found, and how to start one. let (code, v, _) = e.json(&["room", "show", "--target", "local"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("stack-not-found")), "{v}"); assert!(v["error"]["hint"].as_str().unwrap().contains("wai dev"), "{v}"); // A target in use that is gone from every list is not found either. let (url, _rx) = service(vec![]); assert_eq!(e.json(&["target", "add", "gone", "--url", &url]).0, Some(0)); assert_eq!(e.json(&["target", "use", "gone"]).0, Some(0)); let own = e.config().join("targets.json"); let mut l: Value = serde_json::from_str(&std::fs::read_to_string(&own).unwrap()).unwrap(); l["targets"] = json!({}); std::fs::write(&own, l.to_string()).unwrap(); let (code, v, _) = e.json(&["room", "show"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("target-not-found")), "{v}"); let _ = std::fs::remove_dir_all(&e.root); } /// The record `wai dev` keeps in `.wai/dev/stack.json` while its stack runs, its origin `url` (a /// stand-in), its process `pid`. fn stack_record(e: &Env, url: &str, pid: u64) -> PathBuf { let dir = e.project().join(".wai").join("dev"); std::fs::create_dir_all(&dir).unwrap(); let rec = json!({ "format": "wai.dev-stack/1", "toolchain": env!("CARGO_PKG_VERSION"), "pid": pid, "dir": dir.display().to_string(), "origin": url, "room_api": format!("{url}/room-api"), "service": url, "relay": "ws://127.0.0.1:9/moq", "room_bin": env!("CARGO_BIN_EXE_wai"), "relay_quic": null, "relay_cert_sha256": null, "impair": null, }); std::fs::write(dir.join("stack.json"), rec.to_string()).unwrap(); dir } /// What the stack's origin answers `GET /_wai/api/state` with: its directory and process. fn state(dir: &Path, pid: u64) -> (u16, Value) { (200, json!({"ready": true, "dir": dir.display().to_string(), "services": [{"pid": pid}]})) } /// `--target local` is the room service of the stack wai dev runs here: its record names its /// origin, which is asked first whether it is that stack, then the operation's route is sent there. /// A record whose stack has gone is stale, and nothing is sent. #[test] fn local_is_the_room_service_of_the_stack_wai_dev_runs_here() { let e = Env::new("local"); let dir = stack_record(&e, "http://127.0.0.1:9", 4242); let (url, rx) = service(vec![state(&dir, 4242), (200, json!({"room": "abc", "publishers": 0}))]); stack_record(&e, &url, 4242); let (code, v, o) = e.json(&["room", "show", "--target", "local"]); assert_eq!(code, Some(0), "{v} {}", text(&o)); assert_eq!((v["data"]["body"]["room"].as_str(), v["data"]["target"]["name"].as_str(), v["data"]["request"].as_str()), (Some("abc"), Some("local"), Some("GET /room")), "{v}"); let probe = got(&rx); assert_eq!((probe.line.as_str(), probe.header("x-wai-studio")), ("GET /_wai/api/state HTTP/1.1", Some("1")), "{probe:?}"); assert!(got(&rx).line.starts_with("GET /room-api/room "), "the route, through the stack's origin"); // A grant on the local room changes it: it waits for --yes, and nothing is sent. let (url, rx) = service(vec![state(&dir, 4242)]); stack_record(&e, &url, 4242); let (code, v, _) = e.json(&["room", "grant", "--target", "local", "--body", r#"{"principal":"00","role":"viewer"}"#]); assert_eq!((code, v["error"]["code"].as_str()), (Some(4), Some("confirmation-required")), "{v}"); assert_eq!(got(&rx).line, "GET /_wai/api/state HTTP/1.1"); nothing_sent(&rx); // Another process answers at the origin: the record is stale, and nothing else is sent. let (url, rx) = service(vec![state(&dir, 1)]); stack_record(&e, &url, 4242); let (code, v, _) = e.json(&["room", "show", "--target", "local"]); assert_eq!((code, v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some(6), Some("stack-stale"), Some(true)), "{v}"); let _ = got(&rx); nothing_sent(&rx); let _ = std::fs::remove_dir_all(&e.root); } /// The development room's doors on the command line, against a stand-in stack: `wai room join` /// asks the stack's room service for a publisher's grant for the principal's key (the key its name /// derives, as wai mcp's) and sends the join to the stack's participants with that grant; `wai room /// leave` and `wai session show` send theirs. A join waits for --yes, a dry run sends nothing, a /// refusal of the stack's is exit 1 with its reason, and a stack that is not running is refused. #[test] fn the_development_rooms_doors_drive_the_stack_wai_dev_runs_here() { let e = Env::new("room-doors"); let pid = 5151; let joined = json!({"ok": true, "id": 1, "room": "r00m", "principal": "ab", "events": ["join", "publish", "publish"], "objects_published": 2, "objects_served": 2}); let dir = stack_record(&e, "http://127.0.0.1:9", pid); let (url, rx) = service(vec![ state(&dir, pid), (200, json!({"grant": "a1b2", "digest": "d1", "exp": 9})), (200, joined), state(&dir, pid), (200, json!({"ok": true, "events": ["leave"]})), state(&dir, pid), (200, json!({"ok": true, "room": "r00m", "descriptor": "dd", "grants": [], "key_log_head": "kk", "chains": [[]]})), state(&dir, pid), (200, json!({"ok": false, "error": "alice holds no session"})), ]); stack_record(&e, &url, pid); let (code, v, o) = e.json(&["room", "join", "--principal", "alice", "--yes"]); assert_eq!(code, Some(0), "{v} {}", text(&o)); assert_eq!((v["op"].as_str(), v["data"]["name"].as_str(), v["data"]["development_only"].as_bool(), v["data"]["grant_digest"].as_str()), (Some("room.join"), Some("alice"), Some(true), Some("d1")), "{v}"); assert!(v["data"].get("ok").is_none() && v["data"].get("id").is_none(), "{v}"); let key = v["data"]["key"].as_str().unwrap().to_string(); assert_eq!(key.len(), 64); let _ = got(&rx); let g = got(&rx); assert!(g.line.starts_with("POST /room-api/grant "), "{g:?}"); let asked: Value = serde_json::from_str(&g.body).unwrap(); assert_eq!((asked["principal"].as_str(), asked["role"].as_str()), (Some(key.as_str()), Some("publisher")), "{asked}"); let j = got(&rx); assert!(j.line.starts_with("POST /_wai/api/control ") && j.header("x-wai-studio") == Some("1"), "{j:?}"); let line: Value = serde_json::from_str(&j.body).unwrap(); assert_eq!((line["op"].as_str(), line["grant"].as_str(), line["objects"].as_u64(), line["receipts"].as_bool()), (Some("join"), Some("a1b2"), Some(1), Some(true)), "{line}"); assert_eq!(line["seed"].as_str().unwrap().len(), 64); let (code, v, _) = e.json(&["room", "leave", "--principal", "alice", "--yes"]); assert_eq!((code, v["data"]["events"][0].as_str()), (Some(0), Some("leave")), "{v}"); let _ = got(&rx); let l: Value = serde_json::from_str(&got(&rx).body).unwrap(); assert_eq!(l, json!({"op": "leave", "principal": key}), "the same name, the same key"); let (code, v, _) = e.json(&["session", "show"]); assert_eq!((code, v["data"]["descriptor"].as_str(), v["data"]["development_only"].as_bool()), (Some(0), Some("dd"), Some(true)), "{v}"); let _ = got(&rx); assert_eq!(serde_json::from_str::(&got(&rx).body).unwrap(), json!({"op": "session"})); // The participants refuse: exit 1, with their reason. let (code, v, _) = e.json(&["room", "leave", "--principal", "alice", "--yes"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(1), Some("refused")), "{v}"); assert!(v["error"]["message"].as_str().unwrap().contains("alice holds no session"), "{v}"); let _ = (got(&rx), got(&rx)); // Without --yes a join waits, a dry run sends nothing, and neither is sent anything past the probe. let (url, rx) = service(vec![state(&dir, pid)]); stack_record(&e, &url, pid); let (code, v, _) = e.json(&["room", "join", "--principal", "bob"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(4), Some("confirmation-required")), "{v}"); let _ = got(&rx); nothing_sent(&rx); let (code, v, _) = e.json(&["room", "join", "--principal", "bob", "--dry-run"]); assert_eq!((code, v["dry_run"].as_bool(), v["plan"].as_array().map(Vec::len)), (Some(0), Some(true), Some(2)), "{v}"); nothing_sent(&rx); // A principal named by nothing, and objects past the bound, are usage. for args in [&["room", "join", "--yes"][..], &["room", "join", "--principal", "bob", "--objects", "65", "--yes"], &["room", "leave", "--principal", "", "--yes"]] { assert_eq!(e.json(args).0, Some(2), "{args:?}"); } // A record whose stack is another process: stale, and the session is not asked for. let (url, rx) = service(vec![state(&dir, pid + 1)]); stack_record(&e, &url, pid); let (code, v, _) = e.json(&["session", "show"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(6), Some("stack-stale")), "{v}"); let _ = got(&rx); nothing_sent(&rx); // An invitation needs the room's keys, and a room service of this toolchain. let (code, v, _) = e.json(&["room", "invite"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("not-found")), "no keys yet: {v}"); assert_eq!(e.json(&["room", "invite", "--seconds", "0"]).0, Some(2)); #[cfg(unix)] { std::fs::write(dir.join("room-keys.toml"), "# a stand-in\n").unwrap(); let old = e.root.join("old-wai-room"); program::write_program(&old, "#!/bin/sh\necho '{\"wai\":\"envelope/1\",\"op\":\"toolchain.show\",\"ok\":true,\"data\":{\"program\":\"wai-room\",\"version\":\"0.1.0\",\"toolchain\":\"0.0.1\"}}'\n"); let (code, v, _) = e.json(&["room", "invite", "--room", old.to_str().unwrap()]); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("stack-stale")), "a room service of another toolchain: {v}"); } // No stack recorded: not found, for each door. std::fs::remove_file(dir.join("stack.json")).unwrap(); for args in [&["room", "join", "--principal", "bob", "--yes"][..], &["room", "leave", "--principal", "bob", "--yes"], &["session", "show"]] { let (code, v, _) = e.json(args); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("stack-not-found")), "{args:?}: {v}"); } let _ = std::fs::remove_dir_all(&e.root); } /// `wai log tail` and `wai session list` read the stack's directory: a log from a byte, its end, /// whole lines while followed; the relay's summaries, the last N, a line that does not read /// counted and never shown. A log that is not there, or a name that is no log, is refused. #[test] fn the_stacks_logs_and_sessions_are_read_from_its_directory() { let e = Env::new("logs"); let dir = e.project().join(".wai").join("dev"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("relay.log"), "one\ntwo\nthree").unwrap(); let (code, v, _) = e.json(&["log", "tail"]); assert_eq!((code, v["data"]["text"].as_str(), v["data"]["from"].as_u64(), v["data"]["next"].as_u64()), (Some(0), Some("one\ntwo\nthree"), Some(0), Some(13)), "{v}"); let (_, v, _) = e.json(&["log", "tail", "--from", "4"]); assert_eq!(v["data"]["text"], "two\nthree"); let o = e.text(&["log", "tail", "--from", "8"]); assert_eq!(String::from_utf8_lossy(&o.stdout), "three"); // Followed with no stack running: one read of what is there, whole lines first, then the rest. let o = e.cmd(&["log", "tail", "--follow", "--json"]).stdin(Stdio::null()).output().unwrap(); let lines: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.iter().map(|l| l["text"].as_str().unwrap()).collect::(), "one\ntwo\nthree", "{lines:?}"); assert_eq!(lines[0]["text"], "one\ntwo\n"); assert_eq!(e.json(&["log", "tail", "--name", "room"]).1["error"]["code"], "log-not-found"); assert_eq!(e.json(&["log", "tail", "--name", "keys"]).0, Some(2)); assert_eq!(e.json(&["log", "tail", "--for", "1"]).0, Some(2), "--for is --follow's"); let s = |n: u64| json!({"summary": {"session": n, "transport": "websocket", "remote": "127.0.0.1:9", "opened_unix_ms": 10, "closed_unix_ms": 30}, "signature": "00"}); std::fs::write(dir.join("sessions.jsonl"), format!("{}\nnot a summary\n{}\n{}\n", s(1), s(2), s(3))).unwrap(); let (code, v, _) = e.json(&["session", "list"]); assert_eq!((code, v["data"]["sessions"].as_array().map(Vec::len), v["data"]["unread"].as_u64()), (Some(0), Some(3), Some(1)), "{v}"); let (_, v, _) = e.json(&["session", "list", "--last", "1"]); assert_eq!(v["data"]["sessions"], json!([s(3)])); let t = String::from_utf8_lossy(&e.text(&["session", "list"]).stdout).into_owned(); assert!(t.lines().count() == 3 && t.contains("session 2") && t.contains("20 ms"), "{t}"); let o = e.cmd(&["session", "list", "--follow", "--json", "--last", "2"]).stdin(Stdio::null()).output().unwrap(); let got: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(got, vec![s(2), s(3)]); std::fs::remove_dir_all(&dir).unwrap(); assert_eq!(e.json(&["session", "list"]).1["error"]["code"], "sessions-not-found"); assert_eq!(e.json(&["log", "tail"]).1["error"]["code"], "log-not-found"); let _ = std::fs::remove_dir_all(&e.root); } #[test] fn targets_are_kept_chosen_listed_replaced_and_removed() { let e = Env::new("targets"); minimal_project(&e); let (url, rx) = service(vec![(200, json!({"a": 1})), (200, json!({"b": 2})), (200, json!({"enrolments": []}))]); // One in the project, one of the same name in the person's own list, which it hides. assert_eq!(e.json(&["target", "add", "staging", "--url", &url]).0, Some(0)); assert_eq!(e.json(&["target", "add", "staging", "--url", "https://elsewhere.example", "--user"]).0, Some(0)); assert_eq!(e.json(&["target", "add", "fleet", "--url", &format!("{url}/"), "--user"]).0, Some(0)); let (code, v, _) = e.json(&["target", "list"]); assert_eq!(code, Some(0), "{v}"); let rows = v["data"]["targets"].as_array().unwrap(); assert_eq!(rows.len(), 3, "{v}"); assert_eq!((rows[0]["name"].as_str(), rows[0]["kept_in"].as_str(), rows[0]["hidden"].as_bool()), (Some("staging"), Some("wai.json"), Some(false)), "{v}"); let mine = rows.iter().find(|r| r["name"] == "staging" && r["kept_in"] != "wai.json").unwrap(); assert_eq!(mine["hidden"], true, "{v}"); assert_eq!(rows.iter().find(|r| r["name"] == "fleet").unwrap()["url"], json!(url), "kept without its final /"); // In use: an operation that needs a service and names none takes it, the project's of its name. assert_eq!(v["data"]["use"], Value::Null); let (code, v, _) = e.json(&["target", "use", "staging"]); assert_eq!((code, v["data"]["use"].as_str()), (Some(0), Some("staging")), "{v}"); let (code, v, _) = e.json(&["room", "show"]); assert_eq!((code, v["data"]["target"]["name"].as_str()), (Some(0), Some("staging")), "{v}"); assert_eq!(got(&rx).line, "GET /room HTTP/1.0"); // Replacing one waits for --yes, with what it would replace. let (code, v, _) = e.json(&["target", "add", "staging", "--url", "https://other.example"]); assert_eq!((code, v["proposed"][0]["url"].as_str()), (Some(4), Some(url.as_str())), "{v}"); assert!(v["confirm"].as_str().unwrap().ends_with("--yes"), "{v}"); assert!(std::fs::read_to_string(e.project().join("wai.json")).unwrap().contains(&url), "nothing changed"); // The fleet's operations take the target's name, as every --target does. let (code, v, _) = e.json(&["enrolment", "list", "--target", "fleet"]); assert_eq!(code, Some(0), "{v}"); let _ = got(&rx); // Removed from the project, the file is as it was; removing the last of the name ends its use. let (code, _, _) = e.json(&["target", "remove", "staging", "--yes"]); assert_eq!(code, Some(0)); assert_eq!(std::fs::read_to_string(e.project().join("wai.json")).unwrap(), "{\n \"$schema\": \"https://wai.transaction.science/schema/wai.json/1\",\n \"wai\": 1,\n \"name\": \"p\"\n}\n"); let (_, v, _) = e.json(&["target", "list"]); assert_eq!(v["data"]["use"], "staging", "the person's own staging is still there: {v}"); assert_eq!(e.json(&["target", "remove", "staging", "--yes"]).0, Some(0)); let (_, v, _) = e.json(&["target", "list"]); assert_eq!((v["data"]["use"].clone(), v["data"]["targets"].as_array().unwrap().len()), (Value::Null, 1), "{v}"); let _ = std::fs::remove_dir_all(&e.root); } #[test] fn an_operation_of_a_services_api_is_sent_to_its_route() { let e = Env::new("route"); let (url, rx) = service(vec![(200, json!({"granted": true})), (404, json!({"error": "no such room"})), (503, json!({"error": "busy"})), (400, json!({"error": "a bad body"})), (200, json!({"log": []}))]); let body = r#"{"principal":"0000000000000000000000000000000000000000000000000000000000000000","role":"viewer"}"#; // A change waits for --yes: exit 4, with the request it would send, and nothing sent. let (code, v, _) = e.json(&["room", "grant", "--target", &url, "--body", body]); assert_eq!((code, v["proposed"][0]["request"].as_str()), (Some(4), Some("POST /grant")), "{v}"); assert_eq!(v["confirm"].as_str(), Some(format!("wai room grant --target {url} --body '{body}' --yes").as_str()), "{v}"); nothing_sent(&rx); let (code, v, _) = e.json(&["room", "grant", "--target", &url, "--body", body, "--yes"]); assert_eq!((code, v["data"]["status"].as_u64(), v["data"]["body"]["granted"].as_bool(), v["data"]["key"].clone()), (Some(0), Some(200), Some(true), Value::Null), "{v}"); let s = got(&rx); assert_eq!((s.line.as_str(), s.header("content-type"), s.body.as_str()), ("POST /grant HTTP/1.0", Some("application/json"), body)); assert_eq!(s.header("authorization"), None, "no key is kept or set: none is sent"); // The service's refusals, as the toolchain's exits. for (want, code_name) in [(5, "not-found"), (6, "target-unavailable"), (2, "invalid-input")] { let (code, v, _) = e.json(&["room", "show", "--target", &url]); let _ = got(&rx); assert_eq!((code, v["error"]["code"].as_str()), (Some(want), Some(code_name)), "{v}"); } // Query parameters on the route, each encoded. let (code, v, _) = e.json(&["mls", "tail", "--target", &url, "--query", "room=ab cd", "--query", "from=2"]); assert_eq!(code, Some(0), "{v}"); assert_eq!(got(&rx).line, "GET /mls?room=ab%20cd&from=2 HTTP/1.0"); // Usage: a body on a route that takes none, JSON that is not, and a query with no name. for args in [&["room", "show", "--target", &url, "--body", "{}"][..], &["room", "grant", "--target", &url, "--body", "{not json", "--yes"], &["mls", "tail", "--target", &url, "--query", "=x"], &["room", "show", "--target", &url, "extra"]] { assert_eq!(e.json(args).0, Some(2), "{args:?}"); } nothing_sent(&rx); // No service there: unavailable, and worth a retry. let (code, v, _) = e.json(&["service", "check", "--target", "http://127.0.0.1:9"]); assert_eq!((code, v["error"]["retryable"].as_bool()), (Some(6), Some(true)), "{v}"); // The help names its route and the shared flag. let o = e.text(&["room", "grant", "--help"]); assert!(String::from_utf8_lossy(&o.stdout).contains("POST /grant") && String::from_utf8_lossy(&o.stdout).contains("--target"), "{}", text(&o)); let _ = std::fs::remove_dir_all(&e.root); } /// A `wai mcp` process and its protocol channel. struct Mcp { child: std::process::Child, stdin: std::process::ChildStdin, out: BufReader, next: u64, } impl Mcp { fn start(e: &Env, extra: &[&str]) -> Mcp { let mut c = e.cmd(&["mcp"]); let mut child = c.args(extra).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().expect("wai mcp starts"); let stdin = child.stdin.take().unwrap(); let out = BufReader::new(child.stdout.take().unwrap()); let mut m = Mcp { child, stdin, out, next: 1 }; let init = m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_target", "version": "1"}})); assert!(init["result"]["instructions"].as_str().unwrap().contains("the target this server was started with"), "{init}"); m } fn request(&mut self, method: &str, params: Value) -> Value { let id = self.next; self.next += 1; writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap(); self.stdin.flush().unwrap(); let mut l = String::new(); assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output"); serde_json::from_str(&l).unwrap() } fn finish(mut self) -> String { drop(self.stdin); let mut err = String::new(); let _ = self.child.stderr.take().unwrap().read_to_string(&mut err); let _ = self.child.wait(); err } } #[test] fn wai_mcp_serves_a_targets_operations_reading_unless_it_may_write() { let e = Env::new("mcp"); let (url, rx) = service(vec![(200, json!({"room": "abc"})), (401, json!({"error": format!("refused {KEY}")})), (200, json!({"granted": true}))]); assert_eq!(e.json(&["target", "add", "here", "--url", &url]).0, Some(0)); assert!(e.with_input(&["auth", "login", "--url", &url, "--store", "file"], KEY).status.success()); // A target on this machine may be changed; each tool is an operation of the service's API. let mut m = Mcp::start(&e, &["--target", "here"]); let list = m.request("tools/list", json!({})); let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"room_show") && names.contains(&"room_grant") && names.contains(&"envelope_inspect"), "{names:?}"); let show = list["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "room_show").unwrap(); assert_eq!((show["annotations"]["readOnlyHint"].as_bool(), show["annotations"]["openWorldHint"].as_bool(), show["_meta"]["wai/operation"].as_str()), (Some(true), Some(true), Some("room.show")), "{show}"); let r = m.request("tools/call", json!({"name": "room_show", "arguments": {"query": {"from": "1"}}})); assert_eq!((r["result"]["isError"].as_bool(), r["result"]["structuredContent"]["status"].as_u64(), r["result"]["structuredContent"]["key"].as_str()), (Some(false), Some(200), Some("kept")), "{r}"); let s = got(&rx); assert_eq!((s.line.as_str(), s.header("authorization")), ("GET /room?from=1 HTTP/1.0", Some(format!("Bearer {KEY}").as_str()))); let r = m.request("tools/call", json!({"name": "room_show", "arguments": {}})); let _ = got(&rx); assert_eq!(r["result"]["isError"], true, "{r}"); assert!(!r.to_string().contains(KEY) && r.to_string().contains(""), "the key repeated by the service is not passed on: {r}"); let r = m.request("tools/call", json!({"name": "room_grant", "arguments": {"body": {"principal": "00", "role": "viewer"}}})); assert_eq!(r["result"]["structuredContent"]["status"], 200, "{r}"); assert_eq!(got(&rx).body, r#"{"principal":"00","role":"viewer"}"#); let r = m.request("tools/call", json!({"name": "room_show", "arguments": {"body": {}}})); assert!(r["result"]["isError"] == true || r["error"].is_object(), "a body on a route that takes none: {r}"); let err = m.finish(); assert!(!err.contains(KEY), "{err}"); nothing_sent(&rx); // A target on another machine is read alone, unless the server may write; --read-only wins. for (extra, writes) in [(&[][..], false), (&["--allow-write"][..], true), (&["--allow-write", "--read-only"][..], false)] { let mut args = vec!["mcp", "--target", "https://service.example", "--dry-run"]; args.extend_from_slice(extra); let (code, v, _) = e.json(&args); assert_eq!(code, Some(0), "{v}"); let tools: Vec<&str> = v["data"]["tools"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); assert!(tools.contains(&"room_show"), "{tools:?}"); assert_eq!((tools.contains(&"room_grant"), v["data"]["target"]["writes"].as_bool()), (writes, Some(writes)), "{extra:?}: {tools:?}"); } // No target, no service tools; the development tools are local's alone. let (_, v, _) = e.json(&["mcp", "--dry-run"]); assert!(!v["data"]["tools"].as_array().unwrap().iter().any(|t| t == "room_show"), "{v}"); for args in [&["mcp", "--target", "here", "--toolset", "dev", "--dry-run"][..], &["mcp", "--allow-write", "--dry-run"]] { assert_eq!(e.json(args).0, Some(2), "{args:?}"); } let _ = std::fs::remove_dir_all(&e.root); }