Files
wanderbooks/crates/ledger-cli/tests/local_server.rs
T
swaitsandClaude Opus 5.5 a298d154b6 P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an
agent session or a terminal holds them: a host process takes leases on
a socket, starts the server (the hidden mode `wanderbooks server`) with
a pipe on its input, and closes it 10 s after the last lease ends. No
background service. One host even in a race (run/host.lock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:19 -06:00

1066 lines
37 KiB
Rust

//! `wanderbooks server`: every book in the data root on the root's local
//! sockets (ledger-mcp local.rs). Oracles: the INVARIANTS of local.rs and
//! admin.rs, and plan 3.9 (closing: "no new requests, the one in progress
//! finishes, every writer lock is released").
#![cfg(unix)]
use ledger_mcp::admin::{self, Request, Response};
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, ClientConfig};
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use serde_json::{Value, json};
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
type Client = RunningService<RoleClient, ClientConfig>;
/// A home with a data root made by `wanderbooks setup`.
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new(tag: &str) -> Self {
let dir = tempfile::Builder::new()
.prefix(&format!("wb-srv-{tag}-"))
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
}
fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
}
fn run(&self) -> PathBuf {
self.root().join("run")
}
fn admin(&self, request: Request) -> Response {
admin::call(&self.run().join("admin.sock"), request).unwrap()
}
/// The text of every answer must not name the root (local.rs
/// INVARIANT 7).
fn assert_hidden(&self, text: &str) {
let root = self.root().display().to_string();
assert!(!text.contains(&root), "names the data root: {text}");
assert!(
!text.contains(&self.dir.path().display().to_string()),
"names the home: {text}"
);
}
fn create(&self, id: &str, currency: &str) {
match self.admin(Request::Create {
id: id.into(),
currency: currency.into(),
}) {
Response::Created { id: made, problem } => {
assert_eq!(made, id);
assert_eq!(problem, None);
}
other => panic!("{other:?}"),
}
}
}
/// A running `wanderbooks server`, ended (kill) when dropped.
struct Server {
child: Child,
}
impl Server {
/// Start one and wait for its ready line.
fn start(home: &Home) -> Self {
// Its environment is the test home's alone: the default import
// folder and vault key are there, never the real ones.
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut line = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
assert_eq!(line, "ready\n", "the server didn't say ready");
Self { child }
}
/// Close it as the host does (stdin ends) and wait for it to exit.
fn close(mut self) -> std::process::ExitStatus {
drop(self.child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
assert!(Instant::now() < deadline, "the server didn't close");
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// An HTTP client for the server's MCP socket.
fn mcp_http(home: &Home) -> reqwest::Client {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
let path = short.path().to_path_buf();
std::mem::forget(short);
reqwest::Client::builder()
.unix_socket(path)
.build()
.unwrap()
}
async fn connect(home: &Home, book: &str) -> Result<Client, String> {
let http = mcp_http(home);
let transport = StreamableHttpClientTransport::with_client(
http,
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")),
);
ClientConfig::default()
.serve(transport)
.await
.map_err(|e| e.to_string())
}
/// Call a tool: Ok(structured result) or Err(the error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
// A client whose server went away may wait forever: a call that
// takes this long counts as refused.
let r = tokio::time::timeout(
Duration::from_secs(20),
c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
)
.await
.map_err(|_| "no answer".to_string())?
.map_err(|e| format!("protocol: {e}"))?;
let text = r
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default();
if r.is_error == Some(true) {
Err(text)
} else {
Ok(r.structured_content
.unwrap_or_else(|| serde_json::from_str(&text).unwrap_or(Value::String(text))))
}
}
async fn chart(c: &Client) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("6000", "Supplies", "expense"),
] {
call(
c,
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)
.await
.unwrap();
}
}
/// Draft a receipted $1.00 + n entry; post it. Ok(entry id) or Err(why).
async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
let e = call(
c,
"books.draft_entry",
json!({"date": "2026-03-01", "description": format!("entry {n}")}),
)
.await?;
let id = e["entry_id"].as_u64().unwrap();
let amount = format!("{}.{:02}", 1 + n / 100, n % 100);
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
call(
c,
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
)
.await?;
}
call(
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA=="}),
)
.await?;
call(
c,
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "no_party_reason": "test"}),
)
.await?;
Ok(id)
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_book_is_served_on_its_own_route() {
let home = Home::new("routes");
let server = Server::start(&home);
for sock in ["mcp.sock", "admin.sock"] {
assert_eq!(mode(&home.run().join(sock)), 0o600, "{sock}");
}
assert_eq!(home.admin(Request::List), Response::Books(vec![]));
home.create("acme", "USD");
home.create("beta", "EUR");
match home.admin(Request::List) {
Response::Books(books) => {
let ids: Vec<_> = books.iter().map(|b| (b.id.as_str(), b.open)).collect();
assert_eq!(ids, [("acme", true), ("beta", true)]);
}
other => panic!("{other:?}"),
}
// Each route reaches its own books.
let acme = connect(&home, "acme").await.unwrap();
let beta = connect(&home, "beta").await.unwrap();
chart(&acme).await;
let a = call(&acme, "books.chart", json!({})).await.unwrap();
let b = call(&beta, "books.chart", json!({})).await.unwrap();
assert_ne!(a, b, "both routes answered from the same books");
assert!(a.to_string().contains("Supplies"), "{a}");
assert!(!b.to_string().contains("Supplies"), "{b}");
let id = post_one(&acme, 1).await.unwrap();
let entry = call(&acme, "books.get_entry", json!({"entry_id": id}))
.await
.unwrap();
home.assert_hidden(&entry.to_string());
let start = call(&beta, "books.start", json!({})).await.unwrap();
home.assert_hidden(&start.to_string());
assert!(start.to_string().contains("EUR"), "{start}");
// A route for no books says so, and names no folder.
let err = connect(&home, "nobody").await.unwrap_err();
home.assert_hidden(&err);
drop((acme, beta));
assert!(server.close().success());
}
/// local.rs INVARIANT 1: a second server waits for the first (an old
/// one still finishing after its host died), touching nothing, and takes
/// over when it is gone; told to close first, it just goes.
#[test]
fn a_second_server_waits_for_the_first() {
let home = Home::new("second");
let first = Server::start(&home);
home.create("acme", "USD");
let spawn = || {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.env_clear()
.env("HOME", home.dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap()
};
// Told to close while waiting: it goes, having done nothing.
let mut quitter = spawn();
std::thread::sleep(Duration::from_millis(300));
assert!(quitter.try_wait().unwrap().is_none(), "it didn't wait");
drop(quitter.stdin.take());
assert!(wait_exit(&mut quitter, 10).is_some_and(|s| s.success()));
// The first is untouched: its sockets still answer.
assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1));
// Another waits, then takes over once the first has closed.
let mut second = spawn();
std::thread::sleep(Duration::from_millis(300));
assert!(second.try_wait().unwrap().is_none());
assert!(first.close().success());
let mut line = String::new();
BufReader::new(second.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
assert_eq!(line, "ready\n");
assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1 && b[0].open));
drop(second.stdin.take());
assert!(wait_exit(&mut second, 20).is_some_and(|s| s.success()));
}
#[test]
fn closing_lets_go_of_every_book_and_removes_the_sockets() {
let home = Home::new("close");
let server = Server::start(&home);
home.create("acme", "USD");
home.create("beta", "USD");
assert!(server.close().success());
for sock in ["mcp.sock", "admin.sock"] {
assert!(!home.run().join(sock).exists(), "{sock} left behind");
}
// Every writer lock released: the books open, and verify.
for id in ["acme", "beta"] {
let mut store = ledger_store::Store::open(&home.root().join("books").join(id)).unwrap();
store.verify_and_heal().unwrap();
}
// And a new server opens them all again.
let again = Server::start(&home);
assert!(
matches!(home.admin(Request::List), Response::Books(b) if b.len() == 2 && b.iter().all(|b| b.open))
);
assert!(again.close().success());
}
#[test]
fn signals_close_the_server_cleanly() {
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
let home = Home::new("signal");
let mut server = Server::start(&home);
home.create("acme", "USD");
// SAFETY: the pid is our own child's.
assert_eq!(unsafe { libc::kill(server.child.id() as i32, signal) }, 0);
let status = server.child.wait().unwrap();
assert!(status.success(), "signal {signal}: {status:?}");
assert!(ledger_store::Store::open(&home.root().join("books/acme")).is_ok());
}
}
/// The server half of `books_close_cleanly` (plan P2 tests): closing
/// during a burst of posts. Every post either completed (and is in the
/// books) or was refused (and isn't); the books verify at the next open.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn closing_during_a_burst_of_posts_loses_nothing() {
let home = Home::new("burst");
let server = Server::start(&home);
home.create("acme", "USD");
let setup = connect(&home, "acme").await.unwrap();
chart(&setup).await;
drop(setup);
let mut tasks = Vec::new();
for n in 0..4u64 {
let c = connect(&home, "acme").await.unwrap();
tasks.push(tokio::spawn(async move {
// Until the close turns it away: it can't finish first.
let mut posted = Vec::new();
for k in 0.. {
match post_one(&c, n * 100_000 + k).await {
Ok(id) => posted.push(id),
Err(why) => return (posted, why),
}
}
unreachable!()
}));
}
tokio::time::sleep(Duration::from_millis(1500)).await;
let closed = tokio::task::spawn_blocking(move || server.close())
.await
.unwrap();
assert!(closed.success());
let mut posted = Vec::new();
for t in tasks {
let (theirs, why) = t.await.unwrap();
// Each client was stopped by the close, mid-burst.
assert!(
!theirs.is_empty(),
"a client posted nothing before the close"
);
assert!(
why.contains("closing") || why.contains("no answer") || why.contains("protocol"),
"stopped by something else: {why}"
);
posted.extend(theirs);
}
let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap();
store.verify_and_heal().unwrap();
let mut in_books: Vec<u64> = store.ledger().posted_entries().map(|e| e.id()).collect();
in_books.sort_unstable();
posted.sort_unstable();
assert_eq!(
in_books, posted,
"a post was lost or made without its answer"
);
}
#[test]
fn the_admin_socket_refuses_bad_requests_plainly() {
let home = Home::new("admin");
let server = Server::start(&home);
home.create("acme", "USD");
for (request, why) in [
(
Request::Create {
id: "acme".into(),
currency: "USD".into(),
},
"already books called",
),
(
Request::Create {
id: "Bad Id".into(),
currency: "USD".into(),
},
"can't name books",
),
(
Request::Create {
id: "new".into(),
currency: "dollars".into(),
},
"isn't a currency",
),
(
Adopt {
confirmed: true,
..Adopt::new("old", &home.root().join("books/acme"))
}
.request(),
"inside the books' private folder",
),
] {
match home.admin(request) {
Response::Refused(text) => {
assert!(text.contains(why), "{text}");
home.assert_hidden(&text);
}
other => panic!("{other:?}"),
}
}
// A vault without its key, or a key without its vault.
let old = tempfile::Builder::new()
.prefix("wb-srv-badv-")
.tempdir()
.unwrap();
let data = old_books(old.path());
for vault in [
(Some(old.path().join("vault")), None),
(None, Some(old.path().join("vault.key"))),
] {
let r = Request::Adopt {
id: "old".into(),
source: data.clone(),
settings_toml: None,
config_json: None,
vault_dir: vault.0,
vault_key: vault.1,
confirmed: false,
expect_head: None,
skip_unknown: false,
};
match home.admin(r) {
Response::Refused(why) => assert!(why.contains("comes with its key"), "{why}"),
other => panic!("{other:?}"),
}
}
// Raw lines: not JSON, an unknown operation, another version, none.
let sock = home.run().join("admin.sock");
for (line, why) in [
("hello\n", "isn't understood"),
(
"{\"proto\":1,\"op\":\"delete\",\"id\":\"acme\"}\n",
"isn't understood",
),
("{\"proto\":9,\"op\":\"list\"}\n", "another version"),
("{\"proto\":0,\"op\":\"list\"}\n", "another version"),
("{\"proto\":1,\"op\":\"list\"}", "cut off"),
] {
let answer = raw_admin(&sock, line);
assert!(answer.contains(why), "{line:?}: {answer}");
}
assert!(server.close().success());
}
/// Send `line` to the admin socket as is; the answer's text.
fn raw_admin(sock: &Path, line: &str) -> String {
use std::io::{Read, Write};
let mut s = std::os::unix::net::UnixStream::connect(sock).unwrap();
s.write_all(line.as_bytes()).unwrap();
s.shutdown(std::net::Shutdown::Write).unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
out
}
/// An adoption request, built up.
#[derive(Clone, Default)]
struct Adopt {
id: String,
source: PathBuf,
settings_toml: Option<String>,
vault: Option<(PathBuf, PathBuf)>,
confirmed: bool,
expect_head: Option<(u64, String)>,
skip_unknown: bool,
}
impl Adopt {
fn new(id: &str, source: &Path) -> Self {
Self {
id: id.into(),
source: source.to_path_buf(),
..Self::default()
}
}
/// Confirmed, at the head a preview read.
fn at(mut self, head: &(u64, String)) -> Self {
self.confirmed = true;
self.expect_head = Some(head.clone());
self
}
fn request(self) -> Request {
let (vault_dir, vault_key) = match self.vault {
Some((d, k)) => (Some(d), Some(k)),
None => (None, None),
};
Request::Adopt {
id: self.id,
source: self.source,
settings_toml: self.settings_toml,
config_json: None,
vault_dir,
vault_key,
confirmed: self.confirmed,
expect_head: self.expect_head,
skip_unknown: self.skip_unknown,
}
}
}
/// Old books in a project folder, as P1 kept them: a data dir with no
/// settings of its own, and its settings file.
fn old_books(dir: &Path) -> PathBuf {
let data = dir.join("books-data");
let store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
drop(store);
data
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn adoption_previews_then_adopts_and_serves() {
let home = Home::new("adopt");
let server = Server::start(&home);
let old = tempfile::Builder::new()
.prefix("wb-srv-old-")
.tempdir()
.unwrap();
let data = old_books(old.path());
let toml = "[company]\nname = \"Acme Widgets LLC\"\n".to_string();
let plan = Adopt {
settings_toml: Some(toml.clone()),
..Adopt::new("acme", &data)
};
let journal_before = std::fs::read(data.join("journal.jsonl")).unwrap();
let preview = match home.admin(plan.clone().request()) {
Response::Preview(p) => p,
other => panic!("{other:?}"),
};
assert!(!preview.keeps_settings);
assert!(
preview
.settings
.iter()
.any(|s| s.key == "company.name" && s.value == Some(json!("Acme Widgets LLC"))),
"{preview:?}"
);
// A preview writes nothing: no books, the source as it was.
assert_eq!(home.admin(Request::List), Response::Books(vec![]));
assert_eq!(
std::fs::read(data.join("journal.jsonl")).unwrap(),
journal_before
);
let adopted = match home.admin(plan.at(&preview.source_head).request()) {
Response::Adopted(a) => a,
other => panic!("{other:?}"),
};
assert_eq!(adopted.id, "acme");
assert_eq!(adopted.source_head, preview.source_head);
assert_eq!(adopted.settings_imported, preview.settings.len());
assert_eq!(
std::fs::read(data.join("journal.jsonl")).unwrap(),
journal_before
);
// Served at once, with its settings.
let c = connect(&home, "acme").await.unwrap();
let settings = call(&c, "books.settings", json!({})).await.unwrap();
assert!(
settings.to_string().contains("Acme Widgets LLC"),
"{settings}"
);
home.assert_hidden(&settings.to_string());
let verify = call(&c, "books.verify", json!({})).await.unwrap();
home.assert_hidden(&verify.to_string());
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() {
let home = Home::new("vault");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
call(
&c,
"books.add_party",
json!({"name": "Jane Contractor", "roles": []}),
)
.await
.unwrap();
call(
&c,
"books.set_tin",
json!({"party": "Jane Contractor", "tin": "241-40-4915", "tin_type": "ssn"}),
)
.await
.unwrap();
let book = home.root().join("books/acme");
assert_eq!(mode(&book.join("keys")), 0o700);
assert_eq!(mode(&book.join("keys/legacy.key")), 0o600);
assert_eq!(mode(&book.join("vault")), 0o700);
assert!(
std::fs::read_dir(book.join("vault")).unwrap().count() > 0,
"the TIN wasn't sealed into the book's vault"
);
// A file with TINs is never written into the root.
let err = call(
&c,
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": home.root().join("x.csv").display().to_string()}),
)
.await
.unwrap_err();
home.assert_hidden(&err);
assert!(!home.root().join("x.csv").exists());
drop(c);
assert!(server.close().success());
}
/// An MCP session over raw HTTP: initialized, its session id.
async fn raw_session(http: &reqwest::Client, book: &str) -> String {
let url = format!("http://localhost/b/{book}/mcp");
let r = http
.post(&url)
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "test", "version": "0"}}})
.to_string(),
)
.send()
.await
.unwrap();
let session = r.headers()["mcp-session-id"].to_str().unwrap().to_string();
let _ = r.text().await;
http.post(&url)
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", &session)
.header("mcp-protocol-version", "2025-06-18")
.body(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string())
.send()
.await
.unwrap();
session
}
/// Wait for a child to exit, at most `secs`.
fn wait_exit(child: &mut Child, secs: u64) -> Option<std::process::ExitStatus> {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().unwrap() {
return Some(status);
}
std::thread::sleep(Duration::from_millis(20));
}
None
}
/// Review 3a, finding 1: a tool whose client hung up still runs to its
/// end before the server lets go of the books (SharedStore INVARIANT 3).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_call_whose_client_hung_up_finishes_before_the_close() {
let home = Home::new("hangup");
let mut server = Server::start(&home);
home.create("acme", "USD");
let http = mcp_http(&home);
let session = raw_session(&http, "acme").await;
// A large document: its filing takes long enough to close mid-way.
let mut bytes = b"%PDF-1.4\n".to_vec();
bytes.extend((0..20_000_000u32).map(|i| (i % 251) as u8));
use base64_engine::encode;
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
"name": "books.file_document", "arguments": {
"category": "formation", "file_name": "big.pdf",
"mime_type": "application/pdf", "content_base64": encode(&bytes)}}});
let response = http
.post("http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", &session)
.header("mcp-protocol-version", "2025-06-18")
.body(call.to_string())
.send()
.await
.unwrap();
// Hang up with the headers read (the call is running by then), then
// close.
drop(response);
drop(http);
tokio::time::sleep(Duration::from_millis(200)).await;
drop(server.child.stdin.take());
let status = tokio::task::spawn_blocking(move || {
let s = wait_exit(&mut server.child, 120);
(s, server)
})
.await
.unwrap();
assert!(status.0.is_some_and(|s| s.success()), "{:?}", status.0);
// The document is in the books whole, or not at all; never half.
let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap();
store.verify_and_heal().unwrap();
drop(store);
let snap = ledger_store::Snapshot::load(&home.root().join("books/acme")).unwrap();
// It ran to its end before the server let go: filed whole.
let filed = snap.attachments(None).unwrap();
assert_eq!(filed.len(), 1, "the call was cut off");
assert_eq!(snap.attachment_bytes(filed[0].id).unwrap(), bytes);
}
/// Base64 for the tests (standard alphabet, padded).
mod base64_engine {
pub fn encode(bytes: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len() * 4 / 3 + 4);
for c in bytes.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for (i, shift) in [18, 12, 6, 0].iter().enumerate() {
out.push(if i <= c.len() {
T[((n >> shift) & 63) as usize] as char
} else {
'='
});
}
}
out
}
}
/// Send the start of a request that never finishes.
fn stall(home: &Home) -> std::os::unix::net::UnixStream {
use std::io::Write;
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
let mut s = std::os::unix::net::UnixStream::connect(short.path()).unwrap();
s.write_all(
b"POST /b/acme/mcp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nContent-Length: 1000\r\n\r\n{\"jsonrpc\"",
)
.unwrap();
s
}
/// Review 3a, finding 2: a request that never finishes arriving doesn't
/// hold the books open; a second signal stops the wait for it at once.
#[test]
fn a_stalled_request_doesnt_hold_the_books_open() {
let home = Home::new("stall");
let mut server = Server::start(&home);
home.create("acme", "USD");
let _held = stall(&home);
std::thread::sleep(Duration::from_millis(200));
let started = Instant::now();
drop(server.child.stdin.take());
let status = wait_exit(&mut server.child, 40).expect("the server never closed");
assert!(status.success());
assert!(
started.elapsed() < Duration::from_secs(30),
"{:?}",
started.elapsed()
);
let mut server = Server::start(&home);
let _held = stall(&home);
std::thread::sleep(Duration::from_millis(200));
let started = Instant::now();
let pid = server.child.id() as i32;
// SAFETY: our own child.
unsafe { libc::kill(pid, libc::SIGTERM) };
std::thread::sleep(Duration::from_millis(300));
// SAFETY: our own child.
unsafe { libc::kill(pid, libc::SIGTERM) };
let status = wait_exit(&mut server.child, 20).expect("a second signal didn't hurry it");
assert!(status.success());
assert!(
started.elapsed() < Duration::from_secs(9),
"{:?}",
started.elapsed()
);
// And the admin socket, idle, doesn't hold it either.
let mut server = Server::start(&home);
let short = ledger_store::root::SocketPath::new(&home.run(), "admin.sock").unwrap();
let _idle = std::os::unix::net::UnixStream::connect(short.path()).unwrap();
let started = Instant::now();
drop(server.child.stdin.take());
assert!(wait_exit(&mut server.child, 20).is_some_and(|s| s.success()));
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
/// Review 3a, finding 12: told to close while opening, it closes cleanly.
#[test]
fn a_signal_while_opening_still_closes_cleanly() {
let home = Home::new("early");
let server = Server::start(&home);
home.create("acme", "USD");
assert!(server.close().success());
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["server", "--root"])
.arg(home.root())
.env_clear()
.env("HOME", home.dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
// SAFETY: our own child.
unsafe { libc::kill(child.id() as i32, libc::SIGTERM) };
let status = wait_exit(&mut child, 20).unwrap();
// Either caught (exit 0) or, if it came before the handlers, the
// default (killed by the signal) with nothing written half-way.
assert!(status.success() || status.code().is_none(), "{status:?}");
let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap();
store.verify_and_heal().unwrap();
}
/// After kill -9 the next server replaces the stale sockets and serves.
#[test]
fn a_killed_server_is_replaced_cleanly() {
let home = Home::new("kill9");
let mut server = Server::start(&home);
home.create("acme", "USD");
server.child.kill().unwrap();
server.child.wait().unwrap();
assert!(home.run().join("mcp.sock").exists(), "stale socket left");
let again = Server::start(&home);
assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1 && b[0].open));
assert!(again.close().success());
}
/// Review 3a, findings 3 and 4: nothing written into the root, and a
/// vault folder made by the filing guard is private, so TINs can follow.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn nothing_is_written_in_the_root_and_the_vault_stays_private() {
let home = Home::new("cpa");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let book = home.root().join("books/acme");
let err = call(
&c,
"books.export_cpa_package",
json!({"as_of": "2026-10-18", "include_tins": false,
"save_to": book.display().to_string()}),
)
.await
.unwrap_err();
home.assert_hidden(&err);
let zips: Vec<_> = std::fs::read_dir(&book)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".zip"))
.collect();
assert!(zips.is_empty(), "a package was written into the root");
// A TIN file written outside first: the vault folder it records into
// is made private, and storing a TIN then works.
let out = tempfile::Builder::new()
.prefix("wb-srv-out-")
.tempdir()
.unwrap();
let _ = call(
&c,
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": out.path().join("1099.csv").display().to_string()}),
)
.await;
if book.join("vault").exists() {
assert_eq!(mode(&book.join("vault")), 0o700);
}
call(
&c,
"books.add_party",
json!({"name": "Jane Contractor", "roles": []}),
)
.await
.unwrap();
call(
&c,
"books.set_tin",
json!({"party": "Jane Contractor", "tin": "241-40-4915", "tin_type": "ssn"}),
)
.await
.unwrap();
assert_eq!(mode(&book.join("vault")), 0o700);
// books.export_settings works for books that read no file.
call(&c, "books.export_settings", json!({})).await.unwrap();
drop(c);
assert!(server.close().success());
}
/// Old books with a TIN vault, as P1 kept them: the data dir, `vault/`
/// beside it holding one sealed TIN, and its key.
fn old_books_with_vault(dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
let data = old_books(dir);
let vault = dir.join("vault");
let key = dir.join("vault.key");
let v = ledger_vault::Vault::open(&vault, &key, true).unwrap();
v.put_tin(
1,
&ledger_vault::Tin::new("241-40-4915", ledger_vault::TinType::Ssn).unwrap(),
)
.unwrap();
(data, vault, key)
}
#[test]
fn adoption_checks_the_vault_key_and_the_settings_file() {
let home = Home::new("adoptv");
let server = Server::start(&home);
let old = tempfile::Builder::new()
.prefix("wb-srv-oldv-")
.tempdir()
.unwrap();
let (data, vault, key) = old_books_with_vault(old.path());
// Another key doesn't open the records: refused, nothing made.
let wrong = old.path().join("other.key");
ledger_vault::Vault::open(&old.path().join("v2"), &wrong, true).unwrap();
let toml = "[company]\nname = \"Acme\"\n".to_string();
let with = |key: &Path| Adopt {
settings_toml: Some(toml.clone()),
vault: Some((vault.clone(), key.to_path_buf())),
..Adopt::new("acme", &data)
};
match home.admin(with(&wrong).request()) {
Response::Refused(why) => assert!(why.contains("doesn't open"), "{why}"),
other => panic!("{other:?}"),
}
// A settings file naming another key file: refused.
let named = Adopt {
settings_toml: Some(format!(
"{toml}[vault]\nkey_file = \"{}\"\n",
wrong.display()
)),
..with(&key)
};
match home.admin(named.request()) {
Response::Refused(why) => assert!(why.contains("key_file"), "{why}"),
other => panic!("{other:?}"),
}
// Entries the books don't know: shown, then refused unless skipped.
let unknown = Adopt {
settings_toml: Some(format!("{toml}bogus_key = 1\n# kept elsewhere\n")),
..with(&key)
};
let preview = match home.admin(unknown.clone().request()) {
Response::Preview(p) => p,
other => panic!("{other:?}"),
};
assert_eq!(preview.vault_records, 1);
assert!(
preview.unknown.iter().any(|u| u.contains("bogus_key")),
"{preview:?}"
);
assert!(
preview
.comments
.iter()
.any(|c| c.contains("kept elsewhere")),
"{preview:?}"
);
match home.admin(unknown.clone().at(&preview.source_head).request()) {
Response::Refused(why) => assert!(why.contains("--skip-unknown"), "{why}"),
other => panic!("{other:?}"),
}
assert_eq!(home.admin(Request::List), Response::Books(vec![]));
// Changed since the preview: refused.
{
let mut s = ledger_store::Store::open(&data).unwrap();
s.add_account("1000", "Bank", ledger_core::AccountType::Asset, None, false)
.unwrap();
}
let skipping = Adopt {
skip_unknown: true,
..unknown
};
// (A child another test is spawning can hold a copy of the old
// books' lock for an instant after `s` let go: asked again then.)
let answer = (0..50)
.map(|_| home.admin(skipping.clone().at(&preview.source_head).request()))
.find(|r| {
let busy = matches!(r, Response::Refused(why) if why.contains("open for writing"));
if busy {
std::thread::sleep(Duration::from_millis(100));
}
!busy
})
.expect("the old books stayed locked");
match answer {
Response::Refused(why) => assert!(why.contains("changed since the preview"), "{why}"),
other => panic!("{other:?}"),
}
// Previewed again and confirmed: adopted, the vault and its key in.
let head = match home.admin(skipping.clone().request()) {
Response::Preview(p) => p.source_head,
other => panic!("{other:?}"),
};
match home.admin(skipping.at(&head).request()) {
Response::Adopted(a) => {
assert_eq!(a.vault_files, 1);
assert!(a.key_copied);
assert_eq!(a.problem, None);
}
other => panic!("{other:?}"),
}
let book = home.root().join("books/acme");
assert_eq!(mode(&book.join("keys/legacy.key")), 0o600);
assert!(book.join("vault/tin-1.bin").is_file());
assert!(server.close().success());
}