525 lines
16 KiB
Rust
525 lines
16 KiB
Rust
//! Black-box integration suite for the standalone `silverbullet` server binary.
|
|
//!
|
|
//! Each test spawns the compiled binary as a subprocess on an ephemeral port
|
|
//! against a fresh temp space and drives its HTTP endpoints with
|
|
//! `reqwest::blocking`. The runtime API is disabled (`SB_RUNTIME_API=0`) so no
|
|
//! headless Chrome is launched — these tests exercise the HTTP surface only and
|
|
//! stay fast and Chrome-independent.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Wraps the spawned server process and kills + reaps it on drop, so a server
|
|
/// never leaks even if an assertion panics mid-test.
|
|
struct Server(Child);
|
|
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
mod common;
|
|
use common::free_port;
|
|
|
|
/// Spawn the binary against a fresh temp space with the given extra env, wait
|
|
/// for `/.ping` to answer 200 (up to ~20s), and return the running server, the
|
|
/// temp-space handle, and the base URL. On a boot timeout the child's
|
|
/// stdout/stderr are dumped. The `TempDir` is returned (rather than dropped
|
|
/// here) so the space outlives the server for the duration of the test.
|
|
fn start(extra_env: &[(&str, &str)]) -> (Server, tempfile::TempDir, String) {
|
|
let space = tempfile::tempdir().unwrap();
|
|
let port = free_port();
|
|
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_silverbullet"));
|
|
cmd.arg(space.path())
|
|
.arg("--single")
|
|
.arg("-p")
|
|
.arg(port.to_string())
|
|
.arg("-L")
|
|
.arg("127.0.0.1")
|
|
// Critical: never launch headless Chrome for these HTTP-only tests.
|
|
.env("SB_RUNTIME_API", "0")
|
|
.env("SB_DISABLE_SERVICE_WORKER", "1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
for (k, v) in extra_env {
|
|
cmd.env(k, v);
|
|
}
|
|
let child = cmd.spawn().expect("spawn silverbullet");
|
|
let mut server = Server(child);
|
|
|
|
let base = format!("http://127.0.0.1:{port}");
|
|
let http = reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.build()
|
|
.unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
let up = http
|
|
.get(format!("{base}/.ping"))
|
|
.send()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if up {
|
|
break;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
dump_and_panic(&mut server, "server never answered /.ping");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
|
|
(server, space, base)
|
|
}
|
|
|
|
/// Kill the child, drain its captured output, and panic with diagnostics.
|
|
fn dump_and_panic(server: &mut Server, msg: &str) -> ! {
|
|
let _ = server.0.kill();
|
|
let _ = server.0.wait();
|
|
let mut out = String::new();
|
|
if let Some(mut s) = server.0.stdout.take() {
|
|
let _ = s.read_to_string(&mut out);
|
|
}
|
|
let mut err = String::new();
|
|
if let Some(mut s) = server.0.stderr.take() {
|
|
let _ = s.read_to_string(&mut err);
|
|
}
|
|
panic!("{msg}\n--- server stdout ---\n{out}\n--- server stderr ---\n{err}");
|
|
}
|
|
|
|
/// A reqwest blocking client with a sane timeout.
|
|
fn client() -> reqwest::blocking::Client {
|
|
reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
/// A reqwest blocking client that does NOT auto-follow redirects, so 302/401
|
|
/// responses surface directly for the auth/redirect assertions.
|
|
fn no_redirect_client() -> reqwest::blocking::Client {
|
|
reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Open server (no auth)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ping_reports_server_version() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
let resp = http.get(format!("{base}/.ping")).send().unwrap();
|
|
assert!(resp.status().is_success(), "ping status {}", resp.status());
|
|
assert!(
|
|
resp.headers().get("x-server-version").is_some(),
|
|
"missing x-server-version header: {:?}",
|
|
resp.headers()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_returns_boot_config_json() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
let resp = http.get(format!("{base}/.config")).send().unwrap();
|
|
assert!(resp.status().is_success());
|
|
let v: serde_json::Value = resp.json().unwrap();
|
|
assert!(v.get("indexPage").is_some(), "missing indexPage: {v}");
|
|
assert!(v.get("readOnly").is_some(), "missing readOnly: {v}");
|
|
}
|
|
|
|
#[test]
|
|
fn fs_put_get_roundtrip_with_meta_headers() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
|
|
let put = http
|
|
.put(format!("{base}/.fs/test.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("# Hello")
|
|
.send()
|
|
.unwrap();
|
|
assert!(put.status().is_success(), "PUT status {}", put.status());
|
|
|
|
let get = http.get(format!("{base}/.fs/test.md")).send().unwrap();
|
|
assert!(get.status().is_success(), "GET status {}", get.status());
|
|
// Metadata headers emitted by fs.rs::set_file_meta_headers.
|
|
assert!(
|
|
get.headers().get("x-last-modified").is_some(),
|
|
"missing X-Last-Modified"
|
|
);
|
|
assert!(
|
|
get.headers().get("x-created").is_some(),
|
|
"missing X-Created"
|
|
);
|
|
assert!(
|
|
get.headers().get("x-content-length").is_some(),
|
|
"missing X-Content-Length"
|
|
);
|
|
let body = get.text().unwrap();
|
|
assert_eq!(body, "# Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn fs_get_meta_returns_empty_body_with_headers() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
http.put(format!("{base}/.fs/test.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("# Hello")
|
|
.send()
|
|
.unwrap();
|
|
|
|
let resp = http
|
|
.get(format!("{base}/.fs/test.md"))
|
|
.header("X-Get-Meta", "true")
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
assert!(resp.headers().get("x-last-modified").is_some());
|
|
assert!(resp.headers().get("x-content-length").is_some());
|
|
let body = resp.bytes().unwrap();
|
|
assert!(body.is_empty(), "X-Get-Meta body should be empty: {body:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn fs_list_includes_written_file() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
http.put(format!("{base}/.fs/test.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("# Hello")
|
|
.send()
|
|
.unwrap();
|
|
|
|
// Without X-Sync-Mode.
|
|
let resp = http.get(format!("{base}/.fs/")).send().unwrap();
|
|
assert!(resp.status().is_success(), "list status {}", resp.status());
|
|
let files: serde_json::Value = resp.json().unwrap();
|
|
assert!(
|
|
files
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|f| f["name"] == "test.md"),
|
|
"listing missing test.md: {files}"
|
|
);
|
|
|
|
// With X-Sync-Mode: true.
|
|
let resp = http
|
|
.get(format!("{base}/.fs/"))
|
|
.header("X-Sync-Mode", "true")
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
let files: serde_json::Value = resp.json().unwrap();
|
|
assert!(files
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|f| f["name"] == "test.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn fs_delete_then_get_is_404_and_missing_is_404() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
http.put(format!("{base}/.fs/test.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("# Hello")
|
|
.send()
|
|
.unwrap();
|
|
|
|
let del = http.delete(format!("{base}/.fs/test.md")).send().unwrap();
|
|
assert!(del.status().is_success(), "DELETE status {}", del.status());
|
|
|
|
let get = http.get(format!("{base}/.fs/test.md")).send().unwrap();
|
|
assert_eq!(get.status(), reqwest::StatusCode::NOT_FOUND);
|
|
|
|
let missing = http
|
|
.get(format!("{base}/.fs/nonexistent.md"))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_nested_and_url_encoded_paths_roundtrip() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
|
|
// Nested path.
|
|
let put = http
|
|
.put(format!("{base}/.fs/sub/dir/page.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("nested")
|
|
.send()
|
|
.unwrap();
|
|
assert!(put.status().is_success());
|
|
let get = http
|
|
.get(format!("{base}/.fs/sub/dir/page.md"))
|
|
.send()
|
|
.unwrap();
|
|
assert!(get.status().is_success());
|
|
assert_eq!(get.text().unwrap(), "nested");
|
|
|
|
// Path containing a space (URL-encoded).
|
|
let put = http
|
|
.put(format!("{base}/.fs/file%20with%20spaces.md"))
|
|
.header("Content-Type", "text/markdown")
|
|
.body("spaced")
|
|
.send()
|
|
.unwrap();
|
|
assert!(put.status().is_success());
|
|
let get = http
|
|
.get(format!("{base}/.fs/file%20with%20spaces.md"))
|
|
.send()
|
|
.unwrap();
|
|
assert!(get.status().is_success());
|
|
assert_eq!(get.text().unwrap(), "spaced");
|
|
}
|
|
|
|
#[test]
|
|
fn fs_binary_content_roundtrips_byte_for_byte() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
|
|
let blob: Vec<u8> = (0..1024).map(|i| (i % 256) as u8).collect();
|
|
let put = http
|
|
.put(format!("{base}/.fs/blob.bin"))
|
|
.header("Content-Type", "application/octet-stream")
|
|
.body(blob.clone())
|
|
.send()
|
|
.unwrap();
|
|
assert!(put.status().is_success());
|
|
|
|
let get = http.get(format!("{base}/.fs/blob.bin")).send().unwrap();
|
|
assert!(get.status().is_success());
|
|
let got = get.bytes().unwrap();
|
|
assert_eq!(got.as_ref(), blob.as_slice(), "binary blob mismatch");
|
|
}
|
|
|
|
#[test]
|
|
fn shell_echo_and_stdin_pipe() {
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
|
|
// echo hello → code 0, stdout contains hello.
|
|
let resp = http
|
|
.post(format!("{base}/.shell"))
|
|
.header("Content-Type", "application/json")
|
|
.body(r#"{"cmd":"echo","args":["hello"]}"#)
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "shell status {}", resp.status());
|
|
let v: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(v["code"], 0, "echo exit code: {v}");
|
|
assert!(
|
|
v["stdout"].as_str().unwrap().contains("hello"),
|
|
"echo stdout: {v}"
|
|
);
|
|
|
|
// stdin piped to cat → stdout echoes the stdin.
|
|
let resp = http
|
|
.post(format!("{base}/.shell"))
|
|
.header("Content-Type", "application/json")
|
|
.body(r#"{"cmd":"cat","args":[],"stdin":"piped"}"#)
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
let v: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(v["code"], 0, "cat exit code: {v}");
|
|
assert!(
|
|
v["stdout"].as_str().unwrap().contains("piped"),
|
|
"cat should echo stdin: {v}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_forwards_to_a_throwaway_upstream() {
|
|
// Hand-rolled raw-TCP upstream: accept one connection, read the request,
|
|
// reply with a fixed HTTP/1.1 200 carrying the body `ok`.
|
|
let upstream = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let upstream_port = upstream.local_addr().unwrap().port();
|
|
let handle = std::thread::spawn(move || {
|
|
if let Ok((mut conn, _)) = upstream.accept() {
|
|
// Drain the request headers (read until we've seen the blank line).
|
|
let mut buf = [0u8; 1024];
|
|
let _ = conn.read(&mut buf);
|
|
let body = b"ok";
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
let _ = conn.write_all(response.as_bytes());
|
|
let _ = conn.write_all(body);
|
|
let _ = conn.flush();
|
|
}
|
|
});
|
|
|
|
let (_srv, _space, base) = start(&[]);
|
|
let http = client();
|
|
let resp = http
|
|
.get(format!("{base}/.proxy/127.0.0.1:{upstream_port}/x"))
|
|
.send()
|
|
.unwrap();
|
|
// The proxy returns 200 and surfaces the upstream status in a header.
|
|
assert!(resp.status().is_success(), "proxy status {}", resp.status());
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get("x-proxy-status-code")
|
|
.map(|v| v.to_str().unwrap()),
|
|
Some("200"),
|
|
"upstream status not surfaced: {:?}",
|
|
resp.headers()
|
|
);
|
|
let body = resp.text().unwrap();
|
|
assert_eq!(body, "ok", "proxied body mismatch");
|
|
|
|
let _ = handle.join();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth server (SB_USER + SB_AUTH_TOKEN)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Spawn a server with a single user `alice:s3cret` and a static bearer token.
|
|
fn start_auth() -> (Server, tempfile::TempDir, String) {
|
|
start(&[("SB_USER", "alice:s3cret"), ("SB_AUTH_TOKEN", "tok123")])
|
|
}
|
|
|
|
#[test]
|
|
fn auth_unauthenticated_config_is_401_with_auth_location() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
let resp = http.get(format!("{base}/.config")).send().unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
|
|
assert_eq!(
|
|
resp.headers().get("location").map(|v| v.to_str().unwrap()),
|
|
Some("/.auth"),
|
|
"expected Location: /.auth"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_bearer_token_grants_config() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
let resp = http
|
|
.get(format!("{base}/.config"))
|
|
.header("Authorization", "Bearer tok123")
|
|
.send()
|
|
.unwrap();
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"bearer-authed config status {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_get_renders_login_page_as_html() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
let resp = http.get(format!("{base}/.auth")).send().unwrap();
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"auth GET status {}",
|
|
resp.status()
|
|
);
|
|
let ct = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(ct.starts_with("text/html"), "content-type: {ct}");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_post_good_credentials_sets_cookie_and_cookie_round_trips() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
|
|
let resp = http
|
|
.post(format!("{base}/.auth"))
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.body("username=alice&password=s3cret")
|
|
.send()
|
|
.unwrap();
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"auth POST status {}",
|
|
resp.status()
|
|
);
|
|
|
|
let set_cookie = resp
|
|
.headers()
|
|
.get("set-cookie")
|
|
.expect("Set-Cookie present on success")
|
|
.to_str()
|
|
.unwrap()
|
|
.to_string();
|
|
// Host-derived cookie name starts with `auth_`.
|
|
assert!(
|
|
set_cookie.starts_with("auth_"),
|
|
"cookie name should start with auth_: {set_cookie}"
|
|
);
|
|
|
|
let v: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(v["status"], "ok", "login JSON: {v}");
|
|
|
|
// Replay the cookie (name=value before the first `;`) against /.config.
|
|
let cookie = set_cookie.split(';').next().unwrap().to_string();
|
|
let resp = http
|
|
.get(format!("{base}/.config"))
|
|
.header("Cookie", cookie)
|
|
.send()
|
|
.unwrap();
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"cookie replay status {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_post_wrong_password_errors_with_no_cookie() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
let resp = http
|
|
.post(format!("{base}/.auth"))
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.body("username=alice&password=wrong")
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
assert!(
|
|
resp.headers().get("set-cookie").is_none(),
|
|
"no Set-Cookie on failed login"
|
|
);
|
|
let v: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(v["status"], "error", "login JSON: {v}");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_logout_redirects_to_auth() {
|
|
let (_srv, _space, base) = start_auth();
|
|
let http = no_redirect_client();
|
|
let resp = http.get(format!("{base}/.logout")).send().unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::FOUND);
|
|
assert_eq!(
|
|
resp.headers().get("location").map(|v| v.to_str().unwrap()),
|
|
Some("/.auth")
|
|
);
|
|
}
|