//! The `server.devices` command against a stub server: the request it makes, //! the rows it lists and the secrets it never carries. //! //! The stub is a plain TCP listener that answers one canned JSON and remembers //! the request line, like the stub in `share_commands.rs`, kept smaller here //! because the command needs one route. mod common; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use common::temp_vault; use notescraft_engine::{Error, Registry, Session, bind_folder, register_core}; use serde_json::{Value, json}; /// The token of the bound vault; it must never reach an answer or an error. const TOKEN: &str = "secret-token"; /// One request the stub received. #[derive(Debug, Clone)] struct Request { /// The HTTP method. method: String, /// The path of the request line. path: String, /// The `Authorization` header, when there was one. auth: Option, } /// A stub server: one canned answer, the requests it saw. struct Stub { /// The base URL of the server. base: String, /// Every request the stub received. seen: Arc>>, /// Whether the listener thread should stop. stop: Arc, } impl Stub { /// Starts a stub on an ephemeral port that answers one route. fn spawn(status: u16, body: &str) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub"); let port = listener.local_addr().expect("addr").port(); let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); let stop = Arc::new(AtomicBool::new(false)); let head = format!("HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()); let thread_seen = Arc::clone(&seen); let thread_stop = Arc::clone(&stop); std::thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { Ok((stream, _)) => { let Ok(mut writer) = stream.try_clone() else { continue }; let mut reader = BufReader::new(stream); if let Some(request) = read_request(&mut reader) { thread_seen.lock().expect("seen").push(request); let _ = writer.write_all(head.as_bytes()); let _ = writer.flush(); } } Err(_) => std::thread::sleep(Duration::from_millis(2)), } } }); Self { base: format!("http://127.0.0.1:{port}"), seen, stop } } /// Every request the stub received. fn requests(&self) -> Vec { self.seen.lock().expect("seen").clone() } } impl Drop for Stub { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); } } /// Reads one request: method, path, the authorization header; no body is expected. fn read_request(reader: &mut BufReader) -> Option { let mut line = String::new(); reader.read_line(&mut line).ok()?; let mut parts = line.split_whitespace(); let method = parts.next()?.to_owned(); let path = parts.next()?.to_owned(); let mut auth = None; loop { let mut header = String::new(); if reader.read_line(&mut header).ok()? == 0 { break; } let trimmed = header.trim_end(); if trimmed.is_empty() { break; } if let Some((name, value)) = trimmed.split_once(':') && name.trim().eq_ignore_ascii_case("authorization") { auth = Some(value.trim().to_owned()); } } Some(Request { method, path, auth }) } /// A vault bound to `stub`, with the registry, the session and the vault guard. fn bound(stub: &Stub, name: &str) -> (Registry, Session, common::TempVault) { let vault = temp_vault(name); bind_folder(vault.path(), &stub.base, "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the vault"); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let session = Session::open(vault.path()).expect("open the vault"); (reg, session, vault) } /// The code of a command error. fn code(result: &Result) -> String { match result { Err(Error::Command { source, .. }) => source.code.clone(), other => panic!("expected a command error, got {other:?}"), } } /// The message of a command error. fn message(result: &Result) -> String { match result { Err(Error::Command { source, .. }) => source.message.clone(), other => panic!("expected a command error, got {other:?}"), } } /// `server.devices` asks the server with the token and lists its rows. #[test] fn the_device_list_goes_to_the_server_with_the_token() { let stub = Stub::spawn( 200, r#"[{"id":"d-1","name":"Pixel 9","created_ms":100,"last_seen_ms":null,"current":true},{"id":"d-2","name":"Laptop","created_ms":50,"last_seen_ms":60,"current":false}]"#, ); let (reg, mut session, _vault) = bound(&stub, "devices"); let answer = reg.run("server.devices", &mut session, &Value::Null).expect("server.devices"); let devices = answer.get("devices").and_then(Value::as_array).expect("the devices"); assert_eq!(devices.len(), 2); assert_eq!(devices.first().and_then(|device| device.get("id")), Some(&json!("d-1"))); assert_eq!(devices.first().and_then(|device| device.get("name")), Some(&json!("Pixel 9"))); assert_eq!(devices.first().and_then(|device| device.get("current")), Some(&json!(true))); assert_eq!(devices.first().and_then(|device| device.get("last_seen_ms")), Some(&Value::Null)); assert_eq!(devices.get(1).and_then(|device| device.get("last_seen_ms")), Some(&json!(60))); let requests = stub.requests(); assert_eq!(requests.len(), 1); assert_eq!(requests.first().map(|request| request.method.as_str()), Some("GET")); assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/devices")); assert_eq!(requests.first().and_then(|request| request.auth.clone()), Some(format!("Bearer {TOKEN}"))); assert!(!answer.to_string().contains(TOKEN), "the token never reaches the answer: {answer}"); } #[test] fn no_token_reaches_the_answer_or_the_rows() { let stub = Stub::spawn(200, r#"[{"id":"d-2","name":"Laptop","created_ms":50,"last_seen_ms":60,"current":false}]"#); let (reg, mut session, _vault) = bound(&stub, "no-token"); let answer = reg.run("server.devices", &mut session, &Value::Null).expect("server.devices"); assert!(!answer.to_string().contains(TOKEN), "{}", answer); assert!(!answer.to_string().contains("token"), "{}", answer); assert!(!answer.to_string().contains("secret"), "{}", answer); } /// A vault that is not bound says so instead of asking the server. #[test] fn an_unbound_vault_reports_not_bound() { let stub = Stub::spawn(200, "[]"); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let vault = temp_vault("devices-unbound"); let mut session = Session::open(vault.path()).expect("open the vault"); let result = reg.run("server.devices", &mut session, &Value::Null); assert_eq!(code(&result), "share"); assert!(message(&result).contains("not bound"), "{}", message(&result)); assert!(stub.requests().is_empty(), "no request was made"); } /// The message of the server reaches the user, the token never does. #[test] fn the_server_answer_refuses_like_the_others() { let stub = Stub::spawn(401, r#"{"error":"token is not valid for this vault"}"#); let (reg, mut session, _vault) = bound(&stub, "devices-401"); let result = reg.run("server.devices", &mut session, &Value::Null); assert_eq!(code(&result), "share"); let text = message(&result); assert!(text.contains("token is not valid for this vault"), "{text}"); assert!(!text.contains(TOKEN), "{text}"); } /// `server.devices` never panics on hostile params, and asks nothing. #[test] fn hostile_params_never_panic_the_devices_command() { let stub = Stub::spawn(200, "[]"); let (reg, mut session, _vault) = bound(&stub, "devices-hostile"); let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(|_| {})); let mut panicked = Vec::new(); for params in notescraft_engine::adversarial_params() { if let Err(Error::Panicked(other)) = reg.run("server.devices", &mut session, ¶ms) { panicked.push(other); } } std::panic::set_hook(previous); assert!(panicked.is_empty(), "commands panicked: {panicked:?}"); }