//! Hostile input never stops the server (SharedStore, server.rs: a request //! that panics stops the books server for every session, so a panic an //! input can reach is a denial of service). //! //! ```text //! GOAL: no argument, resource URI or combination of them reaches a panic, //! an overflow, or an internal error: every call is answered, with a //! result or a refusal, and the server keeps serving. //! SOURCE: DECISION (P0 review round 3, 2026-10-16, finding S1): fail //! closed makes every reachable panic a denial of service, so none //! may be reachable. //! INVARIANTS (oracle): //! 1. Every tool in tools/list, called with arguments generated from its //! input schema and then made hostile (wrong types, missing or extra //! fields, empty, huge, negative and fractional numbers, u64::MAX, //! decimal strings with 30+ digits or absurd exponents, impossible and //! far-off dates and periods, control and format characters, long //! strings), gets a JSON-RPC answer that is no internal error, and a //! tool result that never says the server stopped. //! 2. After every call the server still serves (books.chart answers). //! 3. The same for resources/read with generated URIs. //! 4. After them, the books reopen: a restarted server answers //! books.chart and books.verify (P0 review round 4: a call a command //! accepts but replay refuses would brick the books). //! 5. Books an earlier version wrote (ledger-store's old-books fixtures) //! open, and every tool and resource answers over them: with the //! fixture company's settings where their format keeps settings, on //! the defaults where they wait for their format upgrade. //! ``` //! //! Against a small fixture company with every kind of record, so ids and //! names the generator picks often exist. `MCP_CASES` sets the number of //! cases (default 256). use common::{Place, Wire}; use proptest::prelude::*; use proptest::test_runner::{Config, TestCaseError, TestRunner}; use serde_json::{Map, Value, json}; use std::path::{Path, PathBuf}; use std::time::Duration; mod common; /// The longest any one call may take: a call that hangs is a denial of /// service too. const TIMEOUT: Duration = Duration::from_secs(120); /// What every request gets once the server stopped (server.rs STOPPED). const STOPPED: &str = "stopped taking requests"; fn cases() -> u32 { std::env::var("MCP_CASES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(256) } /// One session with the books (common.rs). struct Mcp { wire: Wire, next_id: u64, } /// The settings the fixture company keeps. const SETTINGS: &str = "[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n"; /// The places one run uses: the test's home and its books (common.rs), a /// folder of files on their way in (as a downloads folder), and the /// folder the server runs in (where a relative path lands), both in the /// home and outside the data root. struct Places { place: Place, /// The books' folder in the data root. books: PathBuf, inbox: PathBuf, sandbox: PathBuf, } fn places(tag: &str) -> Places { let place = Place::new(&format!("hostile-{tag}")); let p = Places { books: place.books_data(), inbox: place.join("inbox"), sandbox: place.join("sandbox"), place, }; for d in [&p.inbox, &p.sandbox.join("out")] { std::fs::create_dir_all(d).unwrap(); } p.place.run_in(&p.sandbox); std::fs::write(p.inbox.join("receipt.pdf"), b"%PDF-1.4 a receipt\n%%EOF\n").unwrap(); std::fs::write( p.inbox.join("bank.csv"), "Date,Description,Amount\n2026-03-01,Coffee,-4.50\n2026-03-02,Deposit,100.00\n", ) .unwrap(); p } impl Mcp { /// Start (or restart) the books' server, and a session with it. fn start(p: &Places) -> Self { Self::session(p.place.start(Some(SETTINGS))) } /// Start (or restart) the server on books an earlier version wrote, /// with the fixture company's settings set as an agent sets them, one /// at a time. Books in a journal format before 2 keep no settings /// until the owner's format upgrade (rules.md B-27, D-39): each change /// is refused as `format_upgrade_pending`, and those books are served /// on the defaults (no members, no 1065, no cash accounts), as they /// would be in a data root. Books with periods closed under the /// calendar year keep it (a changed tax year needs the IRS's consent, /// rules.md B-29), whatever their format. Any other answer fails the /// test when the books are first served (`first`); on a restart, /// after hostile calls that may have changed a setting and closed /// periods under it, the books may refuse a change for what they now /// hold, as they would any agent. fn start_old(p: &Places, first: bool) -> Self { let mut m = Self::session(p.place.start(None)); let changes = common::settings_changes(SETTINGS, None, "USD"); let mut refused = Vec::new(); let mut held = 0; for change in changes.as_array().unwrap() { let r = m.rpc( "tools/call", json!({"name": "books.set_setting", "arguments": {"changes": [change], "decided_by": "Test Owner"}}), ); assert!(r.get("error").is_none(), "{change}: {r}"); if first && r["result"]["isError"] == json!(true) { let why = r["result"]["content"][0]["text"] .as_str() .unwrap_or_default(); if change["key"] == "company.fiscal_year_start" && why.contains("periods are closed under it") { held += 1; continue; } assert!( why.starts_with("format_upgrade_pending:"), "{change}: {why}" ); refused.push(change["key"].clone()); } } assert!( refused.is_empty() || refused.len() + held == changes.as_array().unwrap().len(), "books that take some settings take them all: {refused:?} refused" ); m } fn session(wire: Wire) -> Self { let mut m = Self { wire, next_id: 1 }; let init = m.rpc( "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "hostile", "version": "0"}}), ); assert!(init.get("result").is_some(), "{init}"); m.wire .send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); m } /// One request and its answer (Err: none came within TIMEOUT). fn rpc(&mut self, method: &str, params: Value) -> Value { self.try_rpc(method, params) .unwrap_or_else(|e| panic!("{method}: {e}")) } fn try_rpc(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); self.wire.send(&msg); let v = self.wire.recv(TIMEOUT).ok_or_else(|| { format!( "no answer within {TIMEOUT:?} (or the server died): {:?}", self.wire.refused ) })?; if v.get("id") == Some(&json!(id)) { Ok(v) } else { Err(format!("an answer to another request: {v}")) } } /// A tool's structured result; panics on any refusal (fixture setup). /// Upload `bytes` as an agent does (D-46): the upload_id. fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String { self.wire.upload(bytes, file_name, mime_type, purpose) } fn ok(&mut self, tool: &str, args: Value) -> Value { let r = self.rpc("tools/call", json!({"name": tool, "arguments": args})); assert!( r.get("error").is_none() && r["result"]["isError"] != json!(true), "fixture {tool} {args}: {r}" ); r["result"]["structuredContent"].clone() } } /// Every file under `dir`, with its bytes. fn files_in(dir: &Path) -> std::collections::BTreeMap> { let mut out = std::collections::BTreeMap::new(); for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = e.path(); if p.is_dir() { out.extend(files_in(&p)); } else { out.insert(p.clone(), std::fs::read(&p).unwrap_or_default()); } } out } /// A small company with one of everything, so generated ids and names /// often find a record. fn fixture(m: &mut Mcp, p: &Places) { for (code, name, ty) in [ ("1000", "Bank", "asset"), ("1200", "Receivables", "asset"), ("1300", "Prepaid", "asset"), ("1500", "Equipment", "asset"), ("1501", "Accumulated Depreciation", "asset"), ("2000", "Payables", "liability"), ("2050", "Card", "liability"), ("2400", "Deferred Revenue", "liability"), ("3000", "Capital A", "equity"), ("3001", "Capital B", "equity"), ("4000", "Revenue", "revenue"), ("4900", "Gain", "revenue"), ("6000", "Contract Labor", "expense"), ("6100", "Depreciation", "expense"), ("6300", "Insurance", "expense"), ("6999", "General", "expense"), ] { m.ok( "books.add_account", json!({"code": code, "name": name, "account_type": ty}), ); } m.ok( "books.add_party", json!({"name": "Acme", "roles": ["contractor"]}), ); m.ok("books.add_vendor", json!({"name": "Acme"})); m.ok("books.add_customer", json!({"name": "Client Co"})); m.ok( "books.add_party", json!({"name": "Dana", "roles": ["employee"]}), ); m.ok( "books.add_party", json!({"name": "Member A", "roles": ["member"]}), ); // An unlinked receipt (attachment 1). let up = m.upload(b"%PDF-1.4 a receipt", "r.pdf", "application/pdf", "receipt"); m.ok("books.attach_receipt", json!({"upload_id": up})); for (date, dr, cr, amount) in [ ("2026-01-02", "1000", "3000", "5000.00"), ("2026-01-02", "1000", "3001", "5000.00"), ("2026-02-10", "6000", "1000", "1200.00"), ("2026-03-05", "1500", "2050", "2400.00"), ] { let id = m.ok( "books.draft_entry", json!({"date": date, "description": format!("{dr} from {cr}")}), )["entry_id"] .clone(); for (side, account) in [("debit", dr), ("credit", cr)] { m.ok( "books.add_line", json!({"entry_id": id, "account": account, "side": side, "amount": amount}), ); } let pdf = format!("%PDF-1.4 receipt for {date} {dr} {cr}"); let up = m.upload(pdf.as_bytes(), "r.pdf", "application/pdf", "receipt"); m.ok( "books.attach_receipt", json!({"entry_id": id, "upload_id": up}), ); m.ok( "books.post_entry", json!({"entry_id": id, "confirmed": true}), ); } // A draft left open. let draft = m.ok( "books.draft_entry", json!({"date": "2026-04-01", "description": "open draft"}), )["entry_id"] .clone(); m.ok( "books.add_line", json!({"entry_id": draft, "account": "6999", "side": "debit", "amount": "10.00"}), ); m.ok( "books.register_asset", json!({"description": "Laptop", "purchase_date": "2026-03-05", "cost": "2400.00", "useful_life_months": 24, "cost_account": "1500", "accum_depr_account": "1501", "expense_account": "6100"}), ); m.ok( "books.create_schedule", json!({"kind": "prepaid_expense", "description": "Insurance", "total": "300.00", "start": "2026-01-01", "months": 3, "balance_account": "1300", "pl_account": "6300"}), ); m.ok( "books.create_recurring", json!({"description": "Rent", "frequency": "monthly", "start": "2026-01-01", "lines": [{"account": "6999", "side": "debit", "amount": "50.00"}, {"account": "1000", "side": "credit", "amount": "50.00"}]}), ); m.ok( "books.set_tin", json!({"party": "Member A", "tin": "241-40-4915", "tin_type": "ssn"}), ); let up = m.upload( b"%PDF-1.4 a statement", "s.pdf", "application/pdf", "document", ); m.ok( "books.file_document", json!({"category": "statement", "upload_id": up}), ); let _ = p; } // ------------------------------------------------------------- generators /// Strings that break naive parsers: dates, periods, decimals, ids, /// control and format characters, the long and the empty. fn hostile_strings() -> Vec { let mut v: Vec = [ "", " ", "0", "-0", "1", "-1", "2", "3", "18446744073709551615", "18446744073709551616", "-9223372036854775808", "0.0000000000000000000000000001", "79228162514264337593543950335", "-79228162514264337593543950335", "79228162514264337593543950336", "999999999999999.9999999999", "1000000000000000", "1000000000000001", "123456789012345678901234567890.12", "1e28", "1E-30", "1e999999999", "NaN", "inf", "1.5", "-12.50", "12.345678901234567", "0001-01-01", "0000-00-00", "9999-12-31", "99999-12-31", "+262142-12-31", "-262143-01-01", "+262142-06-30", "2026-02-30", "2026-02-29", "2028-02-29", "2026-13-01", "2026-00-10", " 2026-01-15", "2026-01-15 ", "2026-1-5", "2026-10-16", "2026-12-31", "2025-12-31", "2026-06-30", "999999-01", "262142-12", "262143-01", "0000-01", "0-1", "2026-12", "2026-02", "2026-13", "-2026-01", "2026-01-01T00:00:00Z", "1000", "1500", "6000", "3000", "4000", "9999", "Acme", "Dana", "Member A", "Client Co", "Nobody", "FL-0001", "1/1/2026", "\u{0}", "a\u{0}b", "\n\n\r\t", "\u{202e}evil\u{200b}", "\u{feff}1000", "1000", "291-12-2846", "241404915", "../../../../etc/passwd", "books://chart", "%s%n%x", "{\"a\":1}", "null", "true", "debit", "credit", "asset", "expense", "monthly", "weekly", "yearly", "usd", "EUR", "XXX", "ssn", "ein", "nec", "misc", "cost", "carryover", "personal_use_conversion", "prepaid_expense", "deferred_revenue", "receipt", "document", "statement", "csv", "xlsx", "pdf", "json", "trial_balance", "balance_sheet", "profit_and_loss", "fixed_asset_register", "general_ledger", "month", "quarter", "year", "entry", "account", "company", "all", "contract", ] .iter() .map(|s| s.to_string()) .collect(); v.push("9".repeat(40)); v.push(format!("0.{}", "1".repeat(40))); v.push("A".repeat(5000)); v.push("é".repeat(3000)); v.push("\u{7}".repeat(100)); v.push("2026-01-01".repeat(50)); v } /// Numbers that break naive arithmetic. fn hostile_numbers() -> Vec { vec![ json!(0), json!(1), json!(2), json!(3), json!(4), json!(5), json!(7), json!(12), json!(24), json!(100), json!(-1), json!(i64::MIN), json!(i64::MAX), json!(u64::MAX), json!(u32::MAX), json!(u32::MAX as u64 + 1), json!(i32::MAX), json!(9_007_199_254_740_993u64), json!(1.5), json!(-0.0), json!(1e300), json!(-1e300), json!(2026), json!(9999), json!(10000), json!(262142), json!(1_000_000), ] } /// Paths a tool may be given: inside the sandbox, the inbox, the books, /// relative ones, odd ones. Never anywhere a real file lives. fn hostile_paths(p: &Places) -> Vec { let s = |p: &Path| p.to_str().unwrap().to_string(); vec![ s(&p.sandbox.join("out")), s(&p.sandbox.join("out/new.zip")), s(&p.sandbox.join("out/new.csv")), s(&p.sandbox.join("out/new.pdf")), s(&p.sandbox.join("out/missing-dir/x.zip")), s(&p.inbox.join("receipt.pdf")), s(&p.inbox.join("bank.csv")), s(&p.inbox.join("missing.pdf")), s(&p.inbox.join("x.zip")), format!("{}/../inbox/receipt.pdf", s(&p.inbox)), format!("{}/./receipt.pdf", s(&p.inbox)), s(&p.books.join("journal.jsonl")), s(&p.books.join("out.zip")), s(&p.books.join("keys/legacy.key")), "relative.pdf".into(), "out/rel.zip".into(), String::new(), "\u{0}".into(), format!("{}/{}", s(&p.sandbox.join("out")), "n".repeat(300)), ] } /// What a JSON value of any type can be, hostile: the wrong-type filler. fn any_value(p: &Places) -> BoxedStrategy { let strings = hostile_strings(); let numbers = hostile_numbers(); let paths = hostile_paths(p); prop_oneof![ proptest::sample::select(strings).prop_map(Value::String), proptest::sample::select(numbers), any::().prop_map(Value::Bool), Just(Value::Null), Just(json!([])), Just(json!({})), Just(json!(["1000", 1, null])), proptest::sample::select(paths).prop_map(Value::String), "\\PC{0,24}".prop_map(Value::String), ] .boxed() } /// A field's value, from its schema node, often hostile. fn value_for( name: &str, node: &Value, defs: &Map, p: &Places, depth: u32, ) -> BoxedStrategy { // Follow a reference; take any of anyOf's branches. if let Some(r) = node.get("$ref").and_then(Value::as_str) { let key = r.trim_start_matches("#/$defs/"); let target = defs.get(key).cloned().unwrap_or(json!({})); return value_for(name, &target, defs, p, depth); } if let Some(branches) = node.get("anyOf").and_then(Value::as_array) { let parts: Vec> = branches .iter() .map(|b| value_for(name, b, defs, p, depth)) .collect(); return proptest::strategy::Union::new(parts).boxed(); } let types: Vec = match node.get("type") { Some(Value::String(t)) => vec![t.clone()], Some(Value::Array(ts)) => ts .iter() .filter_map(|t| t.as_str().map(str::to_string)) .collect(), _ => vec![], }; let mut parts: Vec> = Vec::new(); for t in &types { let s = match t.as_str() { "string" => string_for(name, node, p), "integer" | "number" => proptest::sample::select(hostile_numbers()).boxed(), "boolean" => prop_oneof![3 => Just(json!(true)), 1 => Just(json!(false))].boxed(), "null" => Just(Value::Null).boxed(), "array" if depth < 3 => { let item = node.get("items").cloned().unwrap_or(json!({})); let inner = value_for(name, &item, defs, p, depth + 1); prop_oneof![ 8 => proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::Array), 1 => proptest::collection::vec(inner, 40..60).prop_map(Value::Array), ] .boxed() } "object" if depth < 3 => object_for(node, defs, p, depth + 1), _ => any_value(p), }; parts.push(s); } if parts.is_empty() { return any_value(p); } let typed = proptest::strategy::Union::new(parts).boxed(); // Mostly the right type; sometimes any type at all. prop_oneof![6 => typed, 1 => any_value(p)].boxed() } /// A string field's value: what its name suggests, and the hostile set. fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy { let n = name.to_lowercase(); let mut likely: Vec = Vec::new(); if let Some(e) = node.get("enum").and_then(Value::as_array) { likely.extend(e.iter().filter_map(|v| v.as_str().map(str::to_string))); } if n.contains("path") || n.starts_with("save") { return proptest::sample::select(hostile_paths(p)) .prop_map(Value::String) .boxed(); } // A file's SHA-256 (books.begin_upload's): of one of the files // below (so some calls go on past the check), or not. if n.contains("sha256") { likely.extend( HOSTILE_FILES .iter() .map(|b| ledger_store::journal::sha256_hex(b)), ); likely.push("0".repeat(64)); likely.push("not hex".into()); } let pick = |pred: fn(&str) -> bool| -> Vec { hostile_strings().into_iter().filter(|s| pred(s)).collect() }; let date_like = |s: &str| { s.len() >= 8 && s.chars().filter(|c| *c == '-').count() >= 2 || s.is_empty() || s.starts_with(' ') }; let period_like = |s: &str| s.matches('-').count() == 1 || s.is_empty(); let number_like = |s: &str| { !s.is_empty() && s.chars() .all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) && s.matches('-').count() <= 1 }; let dates = [ "date", "as_of", "start", "end", "through", "since", "received_on", "in_service", ]; let amounts = [ "amount", "total", "cost", "deposit", "proceeds", "rate", "percent", "threshold", "balance", "basis", "salvage", "gross", "withholding", "ss_", "medicare", "deductions", "fmv", ]; if n.contains("period") && !n.contains("period_") { likely.extend(pick(period_like)); } else if dates.iter().any(|d| n.contains(d)) { likely.extend(pick(date_like)); } else if amounts.iter().any(|a| n.contains(a)) { likely.extend(pick(number_like)); } let strings = hostile_strings(); let named = if likely.is_empty() { proptest::sample::select(strings.clone()).boxed() } else { proptest::sample::select(likely).boxed() }; prop_oneof![ 3 => named.prop_map(Value::String), 4 => proptest::sample::select(strings).prop_map(Value::String), 1 => "\\PC{0,40}".prop_map(Value::String), ] .boxed() } /// Files an upload may declare, hostile and not (no tool takes a file's /// bytes: D-46). const HOSTILE_FILES: [&[u8]; 5] = [ b"%PDF-1.4", b"", b"\0\0\0", b"571-93-2846", b"Date,Amount\n2026-01-01,1\n", ]; /// An object's value: each property present or not (required ones mostly /// present), and now and then a field no schema names. fn object_for( node: &Value, defs: &Map, p: &Places, depth: u32, ) -> BoxedStrategy { let props = node .get("properties") .and_then(Value::as_object) .cloned() .unwrap_or_default(); let required: Vec = node .get("required") .and_then(Value::as_array) .map(|r| { r.iter() .filter_map(|v| v.as_str().map(str::to_string)) .collect() }) .unwrap_or_default(); let fields: Vec>> = props .iter() .map(|(k, v)| { let key = k.clone(); let present = if required.contains(k) { 0.92 } else { 0.5 }; proptest::option::weighted(present, value_for(k, v, defs, p, depth)) .prop_map(move |o| o.map(|v| (key.clone(), v))) .boxed() }) .collect(); let extra = proptest::option::weighted(0.05, any_value(p)); (fields, extra) .prop_map(|(fields, extra)| { let mut m = Map::new(); for (k, v) in fields.into_iter().flatten() { m.insert(k, v); } if let Some(x) = extra { m.insert("unexpected_field".into(), x); } Value::Object(m) }) .boxed() } /// A tool's arguments: an object from its schema, rarely not an object. fn args_for(schema: &Value, p: &Places) -> BoxedStrategy { let defs = schema .get("$defs") .and_then(Value::as_object) .cloned() .unwrap_or_default(); prop_oneof![ 30 => object_for(schema, &defs, p, 0), 1 => any_value(p), ] .boxed() } /// INVARIANT 1-2 for one answer: no internal error, no "stopped", and the /// server still serves. fn check_answer(m: &mut Mcp, what: &str, r: Result) -> Result<(), TestCaseError> { let r = r.map_err(|e| TestCaseError::fail(format!("{what}: {e}")))?; if let Some(e) = r.get("error") { prop_assert_ne!( e["code"].as_i64(), Some(-32603), "{} got an internal error: {}", what, e ); } let text = r.to_string(); prop_assert!( !text.contains(STOPPED), "{} stopped the server: {}", what, text ); prop_assert!(!text.contains("panicked"), "{} panicked: {}", what, text); let chart = m .try_rpc( "tools/call", json!({"name": "books.chart", "arguments": {}}), ) .map_err(|e| TestCaseError::fail(format!("after {what}: {e}")))?; prop_assert!( chart.get("error").is_none() && chart["result"]["isError"] != json!(true), "after {}, books.chart fails: {}", what, chart ); Ok(()) } /// INVARIANTS 1-2: every tool, hostile arguments, the server serves on. #[test] fn hostile_tool_arguments_never_stop_the_server() { let p = places("tools"); let mut m = Mcp::start(&p); fixture(&mut m, &p); // The files on their way in, and the folder the server runs in: no // call writes or changes one. let outside = (files_in(&p.inbox), files_in(&p.sandbox)); let list = m.rpc("tools/list", json!({})); let tools: Vec<(String, Value)> = list["result"]["tools"] .as_array() .unwrap() .iter() .map(|t| { ( t["name"].as_str().unwrap().to_string(), t["inputSchema"].clone(), ) }) .collect(); assert!(tools.len() > 90, "{} tools", tools.len()); let per_tool: Vec> = tools .iter() .map(|(name, schema)| { let name = name.clone(); args_for(schema, &p) .prop_map(move |a| (name.clone(), a)) .boxed() }) .collect(); let call = proptest::strategy::Union::new(per_tool); // No shrinking: the server is shared, so once one call stops it // every smaller case fails too; the case reported is the first. let mut runner = TestRunner::new(Config { cases: cases(), failure_persistence: None, max_shrink_iters: 0, ..Config::default() }); let m = std::cell::RefCell::new(m); // How the calls were answered: (reached a tool, the tool succeeded), // so the property can't pass by never getting past the schema. let counts = std::cell::Cell::new((0u32, 0u32, 0u32)); runner .run(&call, |(tool, args)| { let mut m = m.borrow_mut(); let what = format!("{tool} {}", truncated(&args)); let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args})); if let Ok(v) = &r { let (n, reached, ok) = counts.get(); let got = v.get("error").is_none(); let fine = got && v["result"]["isError"] != json!(true); counts.set((n + 1, reached + u32::from(got), ok + u32::from(fine))); } check_answer(&mut m, &what, r) }) .unwrap(); let (n, reached, ok) = counts.get(); eprintln!("{n} calls: {reached} reached a tool, {ok} succeeded"); assert!( reached * 2 >= n, "most calls must reach a tool: {reached} of {n}" ); assert!(ok * 20 >= n, "some calls must succeed: {ok} of {n}"); // Every tool is called at least once, whatever the cases drew. let all = tools.clone(); let mut m = m.into_inner(); let mut runner = TestRunner::deterministic(); for (tool, schema) in &all { let args = args_for(schema, &p) .new_tree(&mut runner) .unwrap() .current(); let what = format!("{tool} {}", truncated(&args)); let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args})); check_answer(&mut m, &what, r).unwrap(); } assert!( outside == (files_in(&p.inbox), files_in(&p.sandbox)), "a call wrote outside the books" ); reopens(m, &p, "every tool"); } /// INVARIANT 4: whatever the calls journaled, the books open again. A /// hostile call a command accepts but replay refuses would leave books /// that never reopen (rules.md B-28): the server is restarted on them and /// must answer books.chart and books.verify. fn reopens(m: Mcp, p: &Places, after: &str) { drop(m); let mut m = Mcp::start(p); for tool in ["books.chart", "books.verify"] { let r = m .try_rpc("tools/call", json!({"name": tool, "arguments": {}})) .unwrap_or_else(|e| panic!("after {after}, restarted: {tool}: {e}")); assert!( r.get("error").is_none() && r["result"]["isError"] != json!(true), "after {after}, the books don't reopen: {tool}: {r}" ); } } /// The id of the party named `name`. fn party_id(m: &mut Mcp, name: &str) -> Value { let parties = m.ok("books.parties", json!({})); parties["parties"] .as_array() .unwrap() .iter() .find(|p| p["name"] == name) .unwrap_or_else(|| panic!("no party {name}: {parties}"))["party_id"] .clone() } /// A pinned call: it must reach the tool (no JSON-RPC error at all, so a /// mistyped argument can't make the check vacuous), then INVARIANTS 1-2. fn pinned(m: &mut Mcp, tool: &str, args: Value) -> Value { let what = format!("{tool} {}", truncated(&args)); let r = m .try_rpc("tools/call", json!({"name": tool, "arguments": args})) .unwrap_or_else(|e| panic!("{what}: {e}")); assert!( r.get("error").is_none(), "{what} never reached the tool: {r}" ); check_answer(m, &what, Ok(r.clone())).unwrap(); r["result"].clone() } /// The calls that once stopped the server, each pinned (P0 review round 3, /// S1, and the audit after it): each is answered, and the server serves on. #[test] fn inputs_that_once_panicked_are_refused_or_answered() { let p = places("known"); let mut m = Mcp::start(&p); fixture(&mut m, &p); const MAX: &str = "79228162514264337593543950335"; let big = "900000000000000.00"; let acme = party_id(&mut m, "Acme"); let client = party_id(&mut m, "Client Co"); let lines = |a: &str, b: &str| { json!([{"account": "6999", "side": "debit", "amount": a}, {"account": "6999", "side": "debit", "amount": b}, {"account": "1000", "side": "credit", "amount": "1"}]) }; let payroll = |fed: &str, ss: &str| { json!([{"employee": "Dana", "gross_pay": "1", "federal_withholding": fed, "state_withholding": fed, "ss_employee": "0", "medicare_employee": "0", "ss_employer": ss, "medicare_employer": "1", "other_deductions": "0"}]) }; let schedule = |start: &str, months: Value| { json!({"kind": "prepaid_expense", "description": "x", "total": "100.00", "start": start, "months": months, "balance_account": "1300", "pl_account": "6300"}) }; let asset = |date: &str, months: Value| { json!({"description": "x", "purchase_date": date, "cost": "100.00", "useful_life_months": months, "cost_account": "1500", "accum_depr_account": "1501", "expense_account": "6100"}) }; let calls: Vec<(&str, Value)> = vec![ ("books.run_depreciation", json!({"period": "999999-01"})), ( "books.run_depreciation", json!({"period": "999999-01", "asset_id": 1}), ), ("books.run_depreciation", json!({"period": "262142-12"})), ("books.run_depreciation", json!({"period": "9999-12"})), ("books.create_schedule", schedule("+262142-12-15", json!(1))), ( "books.create_schedule", schedule("+262142-06-01", json!(12)), ), ("books.create_schedule", schedule("9999-12-15", json!(2))), ( "books.create_schedule", schedule("2026-01-01", json!(u32::MAX)), ), ("books.list_schedules", json!({})), ("books.run_schedules", json!({"through": "9999-12-31"})), ("books.register_asset", asset("+262142-11-30", json!(1))), ("books.register_asset", asset("9999-11-30", json!(1))), ("books.register_asset", asset("2026-01-01", json!(u32::MAX))), ("books.asset_schedule", json!({"asset_id": 2})), ("books.asset_schedule", json!({"asset_id": 3})), ("books.list_assets", json!({})), ( "books.register_asset", json!({"description": "y", "purchase_date": "2026-01-01", "cost": "100.00", "useful_life_months": 12, "cost_account": "1500", "accum_depr_account": "1501", "expense_account": "6100", "tax_basis_rule": "carryover", "contributor_basis": MAX, "fmv_at_contribution": MAX}), ), ( "books.set_asset_tax_basis", json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX, "fmv_at_contribution": MAX}), ), ( "books.export", json!({"report": "fixed_asset_register", "format": "xlsx"}), ), ( "books.create_invoice", json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01", "lines": [{"description": "a", "amount": MAX}, {"description": "b", "amount": "1"}], "confirmed": true}), ), ( "books.create_bill", json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01", "vendor_ref": "H-1", "lines": [{"description": "a", "amount": MAX, "account": "6000"}, {"description": "b", "amount": "1", "account": "6000"}], "existing_attachment_id": 1, "confirmed": true}), ), ( "books.create_invoice", json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31", "lines": [{"description": "a", "amount": "1"}], "confirmed": true}), ), ( "books.record_payout", json!({"date": "2026-05-01", "description": "x", "deposit": "1", "deposit_account": "1000", "revenue": [{"account": "4000", "amount": MAX}, {"account": "4000", "amount": "1"}]}), ), ( "books.record_payout", json!({"date": "2026-05-01", "description": "x", "deposit": format!("-{MAX}"), "deposit_account": "1000", "revenue": [{"account": "4000", "amount": big}], "fees": [{"account": "6999", "amount": format!("-{MAX}")}]}), ), ( "books.run_payroll", json!({"date": "2026-05-01", "cash_account": "1000", "lines": payroll(MAX, "1")}), ), ( "books.run_payroll", json!({"date": "2026-05-01", "cash_account": "1000", "lines": payroll("0", MAX)}), ), ( "books.create_recurring", json!({"description": "x", "frequency": "monthly", "start": "2026-01-01", "lines": lines(MAX, "1")}), ), ( "books.dispose_asset", json!({"asset_id": 1, "date": "2026-06-30", "proceeds": MAX, "proceeds_account": "1000", "gain_loss_account": "4900"}), ), ( "books.dispose_asset", json!({"asset_id": 1, "date": "+262142-12-31", "proceeds": "1", "proceeds_account": "1000", "gain_loss_account": "4900"}), ), ( "books.reconcile", json!({"account": "1000", "statement_date": "2026-06-30", "statement_balance": format!("-{MAX}")}), ), ( "books.record_statement", json!({"attachment_id": 2, "account": "1000", "period_start": "2026-06-01", "period_end": "2026-06-30", "opening_balance": "0", "closing_balance": format!("-{MAX}")}), ), ( "books.reconcile", json!({"account": "1000", "statement_attachment": 2}), ), ( "books.export", json!({"report": "balance_sheet", "format": "xlsx", "as_of": "+262142-06-30"}), ), ( "books.export", json!({"report": "balance_sheet", "format": "pdf", "as_of": "-262143-01-01"}), ), ( "books.export", json!({"report": "balance_sheet", "format": "pdf", "as_of": "9999-12-31"}), ), ( "books.export_cpa_package", json!({"as_of": "+262142-06-30", "cash_accounts": ["1000"], "include_tins": false}), ), ( "books.export_cpa_package", json!({"as_of": "9999-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), ( "books.profit_and_loss", json!({"start": "0001-01-01", "end": "9999-12-31"}), ), ( "books.profit_and_loss", json!({"start": "-262143-01-01", "end": "+262142-12-31"}), ), ( "books.show", json!({"report": "profit_and_loss", "start": "+10000-01-01", "end": "+10000-12-31"}), ), ( "books.create_recurring", json!({"description": "x", "frequency": "weekly", "start": "-262143-01-01", "lines": lines("1", "1")}), ), ("books.run_recurring", json!({"through": "9999-12-31"})), ("books.vendor_1099", json!({"year": 2026})), ("books.vendor_1099", json!({"year": 262142})), ("books.vendor_1099", json!({"year": i64::MIN})), ("books.digest", json!({"since": "+262142-12-31"})), ( "books.create_period", json!({"name": "far", "start": "+262142-12-01", "end": "+262142-12-31"}), ), ( "books.create_period", json!({"name": "far", "start": "9999-12-01", "end": "9999-12-31"}), ), ]; for (tool, args) in calls { pinned(&mut m, tool, args); } // Two vendors' shares of one payment at the line limit (the 1099 // worksheet multiplies a payment by a share), and members' capital at // it (the K-1 worksheet multiplies too). let id = m.ok( "books.draft_entry", json!({"date": "2026-05-02", "description": "huge but legal"}), )["entry_id"] .clone(); for (account, side, amount) in [ ("6000", "debit", big), ("1000", "credit", "450000000000000.00"), ("2050", "credit", "450000000000000.00"), ] { pinned( &mut m, "books.add_line", json!({"entry_id": id, "account": account, "side": side, "amount": amount}), ); } pinned( &mut m, "books.set_entry_party", json!({"entry_id": id, "parties": [{"party": "Acme", "amount": "450000000000000.00"}, {"party": "Dana", "amount": "450000000000000.00"}]}), ); pinned( &mut m, "books.set_entry_vendor", json!({"entry_id": id, "vendor": "Acme"}), ); let up = m.upload(b"%PDF-1.4 huge", "h.pdf", "application/pdf", "receipt"); pinned( &mut m, "books.attach_receipt", json!({"entry_id": id, "upload_id": up}), ); pinned( &mut m, "books.post_entry", json!({"entry_id": id, "confirmed": true}), ); for (tool, args) in [ ("books.vendor_1099", json!({"year": 2026})), ( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), ("books.trial_balance", json!({})), ("books.cash_flow", json!({"cash_accounts": ["1000"]})), ] { pinned(&mut m, tool, args); } // A draft holding a line the books can't sum, read every way. let id = m.ok( "books.draft_entry", json!({"date": "2026-05-01", "description": "huge", "party": "Acme"}), )["entry_id"] .clone(); for amount in [MAX, "1"] { pinned( &mut m, "books.add_line", json!({"entry_id": id, "account": "6999", "side": "debit", "amount": amount}), ); } for (tool, args) in [ ("books.get_entry", json!({"entry_id": id})), ("books.party", json!({"party": "Acme"})), ( "books.set_entry_party", json!({"entry_id": id, "parties": [{"party": "Acme", "amount": MAX}, {"party": "Dana", "amount": "1"}]}), ), ("books.post_entry", json!({"entry_id": id})), ("books.search_entries", json!({"text": "huge"})), ("books.digest", json!({})), ] { pinned(&mut m, tool, args); } // And after a restart, the books open and serve. drop(m); let mut m = Mcp::start(&p); for (tool, args) in [ ("books.chart", json!({})), ("books.list_schedules", json!({})), ("books.list_assets", json!({})), ("books.run_schedules", json!({"through": "2026-12-31"})), ] { pinned(&mut m, tool, args); } } /// INVARIANT 3: resources/read with generated URIs. #[test] fn hostile_resource_uris_never_stop_the_server() { let p = places("resources"); let mut m = Mcp::start(&p); fixture(&mut m, &p); let bases = [ "books://chart", "books://reports/trial-balance", "books://reports/balance-sheet", "books://reports/profit-and-loss", "books://digest", "books://digest?since=", "books://digest?since=+262142-12-31", "books://digest?since=0000-00-00", "books://digest?since=9999-12-31", "books://digest?since=2026-02-30&since=1", "books://handbook", "books://handbook/", "books://handbook/contract", "books://handbook/all", "books://handbook/../contract", "books://receipts/", "books://receipts/1", "books://receipts/0", "books://receipts/18446744073709551615", "books://receipts/18446744073709551616", "books://receipts/-1", "books://nope", "", "books:", "file:///etc/passwd", "books://chart?x=1", ]; let uri = prop_oneof![ proptest::sample::select(bases.to_vec()).prop_map(str::to_string), (proptest::sample::select(bases.to_vec()), "\\PC{0,24}") .prop_map(|(b, s)| format!("{b}{s}")), "\\PC{0,64}", "books://[a-z/?=&.%-]{0,40}", ]; // No shrinking: the server is shared, so once one call stops it // every smaller case fails too; the case reported is the first. let mut runner = TestRunner::new(Config { cases: cases(), failure_persistence: None, max_shrink_iters: 0, ..Config::default() }); let m = std::cell::RefCell::new(m); runner .run(&uri, |uri| { let mut m = m.borrow_mut(); let r = m.try_rpc("resources/read", json!({"uri": uri})); check_answer(&mut m, &format!("resources/read {uri:?}"), r) }) .unwrap(); reopens(m.into_inner(), &p, "the resources"); } /// The arguments, cut for a failure message. fn truncated(v: &Value) -> String { let s = v.to_string(); if s.chars().count() > 600 { format!("{}…", s.chars().take(600).collect::()) } else { s } } /// Every file of one fixture's books (its journal and blobs; never the /// expected figures beside them) copied into `to`. fn copy_books(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for e in std::fs::read_dir(from).unwrap() { let e = e.unwrap(); let name = e.file_name(); if name == "expected.json" { continue; } if e.file_type().unwrap().is_dir() { copy_books(&e.path(), &to.join(&name)); } else { std::fs::copy(e.path(), to.join(&name)).unwrap(); } } } /// The books an earlier wanderbooks wrote (ledger-store's /// tests/fixtures/old-books, made with main 188c90ea's Store API: dates /// past 9999 and before 0001, lines and shares past the line limit, /// sub-cent document and payroll lines, and the edges of chrono's calendar /// and of Decimal), in the order the directory lists them. fn old_books() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books"); let mut dirs: Vec = std::fs::read_dir(&root) .unwrap() .map(|e| e.unwrap().path()) .filter(|p| p.is_dir()) .collect(); dirs.sort(); assert!(dirs.len() >= 14, "{} old books", dirs.len()); dirs } /// Calls that read old books the ways a human or an agent does: every /// report over the widest range a command takes, every record by id. fn reads_of_old_books() -> Vec<(&'static str, Value)> { let wide = json!({"start": "0001-01-01", "end": "9999-12-31"}); let mut calls = vec![ ("books.chart", json!({})), ("books.verify", json!({})), ("books.digest", json!({})), ("books.digest", json!({"since": "0001-01-01"})), ("books.settings", json!({})), ("books.roles", json!({})), ("books.trial_balance", json!({})), ("books.trial_balance", json!({"as_of": "9999-12-31"})), ("books.balance_sheet", json!({})), ("books.balance_sheet", json!({"as_of": "9999-12-31"})), ("books.balance_sheet", json!({"as_of": "0001-01-01"})), ("books.profit_and_loss", wide.clone()), ("books.profit_and_loss", json!({})), ("books.profit_and_loss_by_class", wide.clone()), ("books.cash_flow", wide.clone()), ("books.cash_flow", json!({"cash_accounts": ["1000"]})), ("books.ar_aging", json!({})), ("books.ar_aging", json!({"as_of": "9999-12-31"})), ("books.ap_aging", json!({"as_of": "0001-01-01"})), ("books.list_assets", json!({})), ("books.list_schedules", json!({})), ("books.list_recurring", json!({})), ("books.list_periods", json!({})), ("books.list_payroll_runs", json!({})), ("books.list_receipts", json!({})), ("books.list_vendors", json!({})), ("books.list_classes", json!({})), ("books.projects", json!({})), ("books.parties", json!({})), ("books.statements", json!({})), ("books.reconciliations", json!({})), ("books.attachments", json!({})), ("books.vendor_1099", json!({"year": 2026})), ("books.vendor_1099", json!({"year": 9999})), ("books.search_entries", json!({"text": "e"})), ("books.asset_labels", json!({})), ("books.run_schedules", json!({"through": "9999-12-31"})), ("books.run_recurring", json!({"through": "2026-12-31"})), ("books.run_depreciation", json!({"period": "2026-03"})), ("books.run_depreciation", json!({"period": "9999-12"})), ( "books.reconcile", json!({"account": "1000", "statement_date": "2026-03-31", "statement_balance": "10.00"}), ), ( "books.reconcile", json!({"account": "1000", "statement_attachment": 1}), ), ( "books.export_cpa_package", json!({"as_of": "9999-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), ( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), ]; for id in 1..=3 { calls.push(("books.get_entry", json!({"entry_id": id}))); calls.push(("books.asset_schedule", json!({"asset_id": id}))); calls.push(("books.post_entry", json!({"entry_id": id}))); calls.push(("books.party", json!({"party": id}))); calls.push(( "books.close_period", json!({"period_id": id, "confirmed": false}), )); calls.push(( "books.dispose_asset", json!({"asset_id": id, "date": "9999-12-31", "proceeds": "0.00", "proceeds_account": "1000", "gain_loss_account": "6000"}), )); } for report in [ "trial_balance", "balance_sheet", "profit_and_loss", "cash_flow", "general_ledger", "fixed_asset_register", ] { for format in ["csv", "xlsx", "pdf", "json"] { calls.push(("books.export", json!({"report": report, "format": format}))); calls.push(( "books.export", json!({"report": report, "format": format, "as_of": "9999-12-31", "start": "0001-01-01", "end": "9999-12-31"}), )); } } for (report, interval) in [ ("profit_and_loss", "month"), ("profit_and_loss", "year"), ("balance_sheet", "quarter"), ("cash_flow", "year"), ] { calls.push(( "books.show", json!({"report": report, "start": "0001-01-01", "end": "9999-12-31", "interval": interval}), )); } calls } /// Books an earlier version wrote open, and every tool answers over them /// (rules.md B-28: posted is forever; a figure the books can't compute is /// refused, never a panic). Against each fixture: the reads above, then /// OLD_BOOKS_CASES (default 3) generated calls of every tool; after them /// the server restarts and must open the books again (a call a command /// accepts but replay refuses would leave books that never reopen). #[test] fn old_books_answer_every_tool() { let per_tool: usize = std::env::var("OLD_BOOKS_CASES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3); // Each book on its own server, several at once. let books = old_books(); let workers = std::thread::available_parallelism().map_or(4, |n| n.get().min(8)); let next = std::sync::atomic::AtomicUsize::new(0); let failures: Vec = std::thread::scope(|scope| { let handles: Vec<_> = (0..workers) .map(|_| { scope.spawn(|| { let mut failures = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let Some(dir) = books.get(i) else { break }; failures.extend(old_book_answers_every_tool(dir, per_tool)); } failures }) }) .collect(); handles .into_iter() .flat_map(|h| h.join().expect("a worker panicked")) .collect() }); assert!( failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n") ); } /// INVARIANT 5 for one old book: the failures, if any. fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { let mut failures: Vec = Vec::new(); let name = dir.file_name().unwrap().to_string_lossy().to_string(); let p = places(&format!("old-{name}")); // As an earlier version left them in the data root. copy_books(dir, &p.books); let mut m = Mcp::start_old(&p, true); let list = m.rpc("tools/list", json!({})); let tools: Vec<(String, Value)> = list["result"]["tools"] .as_array() .unwrap() .iter() .map(|t| { ( t["name"].as_str().unwrap().to_string(), t["inputSchema"].clone(), ) }) .collect(); let mut calls: Vec<(String, Value)> = reads_of_old_books() .into_iter() .map(|(t, a)| (t.to_string(), a)) .collect(); let mut runner = TestRunner::deterministic(); for (tool, schema) in &tools { for _ in 0..per_tool { let args = args_for(schema, &p) .new_tree(&mut runner) .unwrap() .current(); calls.push((tool.clone(), args)); } } for (tool, args) in calls { let what = format!("{name}: {tool} {}", truncated(&args)); let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args})); if let Err(e) = check_answer(&mut m, &what, r) { failures.push(e.to_string()); drop(m); m = Mcp::start_old(&p, false); } } for uri in [ "books://chart", "books://reports/trial-balance", "books://reports/balance-sheet", "books://reports/profit-and-loss", "books://digest", ] { let r = m.try_rpc("resources/read", json!({"uri": uri})); if let Err(e) = check_answer(&mut m, &format!("{name}: {uri}"), r) { failures.push(e.to_string()); drop(m); m = Mcp::start_old(&p, false); } } // Whatever the calls wrote, the books reopen. drop(m); let mut m = Mcp::start_old(&p, false); let r = m.try_rpc( "tools/call", json!({"name": "books.chart", "arguments": {}}), ); if let Err(e) = check_answer(&mut m, &format!("{name}: reopened"), r) { failures.push(e.to_string()); } drop(m); failures } /// P3 (defects 8, 15, 16): renames, refused report ranges and tax /// categories at creation, at their edges, are each answered (a result or /// a refusal, never a stop), and the books reopen after them. A new name /// is one line of at most 200 characters (ledger_core::account:: /// MAX_ACCOUNT_NAME): 200 is taken and read back whole; 201, a /// 100,000-character one, a line break or a tab is refused. #[test] fn hostile_renames_ranges_and_categories_are_answered() { let p = places("p3"); let mut m = Mcp::start(&p); fixture(&mut m, &p); let refused = |r: &Value| r["isError"] == json!(true); for (tool, args, refuse) in [ ( "books.rename_account", json!({"account": "6300", "name": ""}), true, ), ( "books.rename_account", json!({"account": "6300", "name": " "}), true, ), ( "books.rename_account", json!({"account": "9999", "name": "X"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "insurance"}), false, ), ( "books.rename_account", json!({"account": "6300", "name": "Contract labor"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Policy 123456789"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Policy ٠١٢٣٤٥٦٧٨"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Risk", "reason": "x".repeat(2_001)}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Risk", "reason": "\u{0}"}), true, ), ( "books.show", json!({"account": "1000", "start": "9999-12-31"}), true, ), ( "books.show", json!({"report": "profit_and_loss", "start": "9999-12-31"}), true, ), ( "books.show", json!({"report": "profit_and_loss", "end": "0001-01-01"}), false, ), ( "books.show", json!({"report": "trial_balance", "end": "9999-12-31"}), false, ), ( "books.profit_and_loss", json!({"start": "9999-12-31"}), true, ), ("books.profit_and_loss", json!({"end": "0001-01-01"}), false), ( "books.profit_and_loss_by_class", json!({"start": "2026-12-31", "end": "2026-01-01"}), true, ), ( "books.cash_flow", json!({"start": "9999-12-31", "cash_accounts": ["1000"]}), true, ), ("books.ar_aging", json!({}), false), ("books.ap_aging", json!({"as_of": "9999-12-31"}), false), ( "books.add_account", json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}), true, ), ( "books.add_account", json!({"code": "4101", "name": "Fees", "account_type": "revenue", "deductible_percent": "1e9"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "N".repeat(201)}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "N".repeat(100_000)}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Risk\nPolicy"}), true, ), ( "books.rename_account", json!({"account": "6300", "name": "Risk\tPolicy"}), true, ), ( "books.add_account", json!({"code": "6310", "name": "X".repeat(201), "account_type": "expense"}), true, ), ( "books.add_account", json!({"code": "6311", "name": "Two\nlines", "account_type": "expense"}), true, ), ] { let r = pinned(&mut m, tool, args.clone()); assert_eq!(refused(&r), refuse, "{tool} {}: {r}", truncated(&args)); } // A name of 200 characters: taken, and read back whole. let long = "N".repeat(200); let r = pinned( &mut m, "books.rename_account", json!({"account": "1000", "name": long}), ); assert!(!refused(&r), "{}", truncated(&r)); let chart = m.ok("books.chart", json!({})); assert!( chart["accounts"] .as_array() .unwrap() .iter() .any(|a| a["code"] == "1000" && a["name"].as_str().unwrap() == long) ); reopens(m, &p, "P3's hostile inputs"); } /// books.project over the whole calendar answers, its months the last 240 /// (each month is a statement: a hundred thousand of them would not). #[test] fn a_project_over_the_whole_calendar_answers_quickly() { let p = places("project-wide"); let mut m = Mcp::start(&p); fixture(&mut m, &p); m.ok("books.add_project", json!({"name": "Harbor"})); let t = std::time::Instant::now(); let p = m.ok( "books.project", json!({"project": "Harbor", "start": "0001-01-01", "end": "9999-12-31"}), ); assert_eq!(p["period"]["by_month"].as_array().unwrap().len(), 240); assert!(t.elapsed() < std::time::Duration::from_secs(30)); }