refactor!: files only by handle: uploads in, downloads out (D-46)
check / check (push) Canceled after 0s

The owner (2026-10-23, via the question tool): "Uploads only"; and
before it: "What we would DEFINITELY want to avoid is passing a bunch
of BLOBs through AI context windows. That's just a pure waste." No file
passes through an agent's context, in either direction.

In: the inline arguments are gone with the 256 KiB limit (INLINE_MAX,
take_file's inline branch): content_base64 and its sha256 on
books.attach_receipt and books.file_document, w9_content_base64 and
w9_sha256 on books.set_w9, csv_text on the two CSV imports. A file comes
only by books.begin_upload and `wanderbooks upload`, named by its
upload_id; existing_attachment_id still names bytes stored already. The
five filing tools deny unknown fields, and any inline argument to any
tool is refused before it runs as an unknown argument pointing to
books.begin_upload (access log: refused inline_file).

Out: books.export (every format), books.asset_labels and
books.get_receipt view bytes hand the file over (HandedFile: download
command and expiry, D-29), as the CPA package and the W-9 do. Resource
reads stay text. A token keeps up to 64 handed files (was 16). The
access log's digest no longer exempts file bytes.

Docs: handbook files-in-out and parties-and-tins, mcp-interface.md,
rules.md, receipt-store.md, parties-and-attachments.md, plan/served.md
(D-46; the inline decisions marked superseded), STATUS.md, testing.md
(D-46: tests migrated to uploads and downloads, rewritten, removed,
added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 09:18:14 -06:00
1 parent d7fa16e974
commit ebb5fcb46f
38 files changed
+1702 -1172

No files matched your search

+67 -9
View File
@@ -162,7 +162,49 @@ impl Drop for Server {
}
}
async fn connect(home: &Home, book: &str, token: String) -> Client {
/// A session with one book, and what it takes to send the book a file as
/// an agent does (D-46: a file comes only by upload).
struct Agent {
client: Client,
http: reqwest::Client,
book: String,
token: String,
}
impl std::ops::Deref for Agent {
type Target = Client;
fn deref(&self) -> &Client {
&self.client
}
}
impl Agent {
/// Upload `bytes` as `wanderbooks upload` does: books.begin_upload,
/// then the bytes PUT with this session's token. The upload_id.
async fn upload(&self, bytes: &[u8], file_name: &str, mime_type: &str) -> String {
let begun = call(
self,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": mime_type, "file_name": file_name, "purpose": "receipt"}),
)
.await
.unwrap();
let id = begun["upload_id"].as_str().unwrap().to_string();
let r = self
.http
.put(format!("http://localhost/b/{}/u/{id}", self.book))
.bearer_auth(&self.token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
id
}
}
async fn connect(home: &Home, book: &str, token: String) -> Agent {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(&home.root().join("run"), "mcp.sock").unwrap();
let path = short.path().to_path_buf();
@@ -173,11 +215,20 @@ async fn connect(home: &Home, book: &str, token: String) -> Client {
.unwrap();
let config =
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"))
.auth_header(token);
ClientConfig::default()
.serve(StreamableHttpClientTransport::with_client(http, config))
.auth_header(token.clone());
let client = ClientConfig::default()
.serve(StreamableHttpClientTransport::with_client(
http.clone(),
config,
))
.await
.unwrap()
.unwrap();
Agent {
client,
http,
book: book.to_string(),
token,
}
}
/// Call a tool: Ok(structured result) or Err(the error text).
@@ -228,7 +279,7 @@ async fn set_targets(c: &Client, remote: Option<&Path>, folder: Option<&Path>) {
}
/// Draft, receipt and post one $n entry.
async fn post_one(c: &Client, n: u64) {
async fn post_one(c: &Agent, n: u64) {
for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Supplies", "expense")] {
let _ = call(
c,
@@ -254,12 +305,19 @@ async fn post_one(c: &Client, n: u64) {
.await
.unwrap();
}
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let upload = c
.upload(
format!("receipt {n}").as_bytes(),
&format!("r{n}.txt"),
"text/plain",
)
.await;
call(
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
json!({"entry_id": id, "upload_id": upload}),
)
.await
.unwrap();
+127 -2
View File
@@ -5,8 +5,8 @@
//! GOAL: a test drives books exactly as an agent reaches them: a
//! `wanderbooks server` on a data root of the test's own, spoken to
//! over the root's MCP socket with a bearer token, with the headers
//! the agents' bridge sends; a file the books make comes back by
//! its download, never inline.
//! the agents' bridge sends; a file goes in by its upload and comes
//! back by its download, never through the session (D-46).
//! SOURCE: plan/served.md D-44 (one way to serve books), 3.1 (the
//! server's sockets, the admin socket), 3.3 (tokens), 4.7
//! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and
@@ -23,6 +23,10 @@
//! reads from the text, made with books.set_setting.
//! 4. The harness answers nothing itself: every message a test is
//! given is one the server sent.
//! 5. A file a test gives the books is uploaded as an agent uploads it:
//! books.begin_upload, then its bytes PUT to the upload route with
//! the session's token, as `wanderbooks upload` sends them; the
//! upload_id is what the tool is given (D-46).
//! ```
#![allow(dead_code)]
@@ -511,6 +515,68 @@ impl Wire {
bytes
}
/// Upload `bytes` as an agent does (INVARIANT 5): books.begin_upload
/// with their SHA-256, size, type, name and purpose, then, if it began
/// one, the bytes PUT to the upload route. books.begin_upload's answer:
/// its upload_id, or the existing_attachment_id of the same bytes
/// stored already (nothing sent then).
pub fn begin_and_send(
&mut self,
bytes: &[u8],
file_name: &str,
mime_type: &str,
purpose: &str,
) -> Value {
let r = self.call(
"books.begin_upload",
upload_args(bytes, file_name, mime_type, purpose),
);
let begun = answer_of(&r).unwrap_or_else(|e| panic!("books.begin_upload: {e}"));
if let Some(id) = begun["upload_id"].as_str() {
let (status, said) = self.put(&format!("u/{id}"), bytes);
assert_eq!(status, 200, "sending {id}: {said}");
}
begun
}
/// [`Wire::begin_and_send`] for bytes not stored yet: the upload_id to
/// name in the tool that files them.
pub fn upload(
&mut self,
bytes: &[u8],
file_name: &str,
mime_type: &str,
purpose: &str,
) -> String {
let begun = self.begin_and_send(bytes, file_name, mime_type, purpose);
begun["upload_id"]
.as_str()
.unwrap_or_else(|| panic!("no upload begun: {begun}"))
.to_string()
}
/// PUT `bytes` to `path` (under `/b/{book}/`) with this session's
/// token, as `wanderbooks upload` does: the status and the text.
pub fn put(&mut self, path: &str, bytes: &[u8]) -> (u16, String) {
let mut req = self
.http
.put(format!("http://localhost/b/{}/{path}", self.book))
.body(bytes.to_vec());
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
self.rt.block_on(async {
let r = req.send().await.expect("the server's socket answers");
let status = r.status().as_u16();
(status, r.text().await.unwrap_or_default())
})
}
/// [`Wire::fetch`], as text.
pub fn fetch_text(&mut self, file: &Value) -> String {
String::from_utf8(self.fetch(file)).expect("the file is text")
}
/// End the session (DELETE, as the bridge does when its agent goes).
pub fn end(&mut self) {
let Some(s) = self.session.take() else { return };
@@ -525,6 +591,29 @@ impl Wire {
}
}
/// books.begin_upload's arguments for `bytes`.
pub fn upload_args(bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> Value {
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": mime_type, "file_name": file_name, "purpose": purpose})
}
/// A `tools/call` answer: Ok(its structured result) or Err(the refusal's
/// or the protocol error's text).
pub fn answer_of(r: &Value) -> Result<Value, String> {
if let Some(e) = r.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string());
}
let res = &r["result"];
let text = res["content"][0]["text"].as_str().unwrap_or_default();
if res["isError"] == json!(true) {
return Err(text.to_string());
}
Ok(res
.get("structuredContent")
.cloned()
.unwrap_or_else(|| serde_json::from_str(text).unwrap_or(Value::String(text.into()))))
}
/// A test's own books: a home, its server, and the books [`BOOK`] made in
/// it, with the settings the test gives.
pub struct Books {
@@ -921,6 +1010,42 @@ pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String>
}
}
/// Upload `bytes` for `book` on the rmcp session `c` (to the server whose
/// `run/` folder is `run`) as an agent does (INVARIANT 5), with `token`
/// (the session's), declared as `(file_name, mime_type, purpose)`: the
/// upload_id.
pub async fn upload_on(
c: &Client,
run: &Path,
book: &str,
token: &str,
bytes: &[u8],
(file_name, mime_type, purpose): (&str, &str, &str),
) -> String {
let begun = call(
c,
"books.begin_upload",
upload_args(bytes, file_name, mime_type, purpose),
)
.await
.unwrap_or_else(|e| panic!("books.begin_upload: {e}"));
let id = begun["upload_id"]
.as_str()
.unwrap_or_else(|| panic!("no upload begun: {begun}"))
.to_string();
let r = mcp_http_builder_at(run)
.build()
.unwrap()
.put(format!("http://localhost/b/{book}/u/{id}"))
.bearer_auth(token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200, "sending {id}");
id
}
/// Fetch the export a tool handed over in `file` (its `download`
/// command's id) from `book` with `token`: its bytes, checked against its
/// size and SHA-256.
+33 -40
View File
@@ -193,6 +193,11 @@ impl Mcp {
}
/// 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!(
@@ -258,12 +263,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
json!({"name": "Member A", "roles": ["member"]}),
);
// An unlinked receipt (attachment 1).
m.ok(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
);
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"),
@@ -282,11 +283,10 @@ fn fixture(m: &mut Mcp, p: &Places) {
);
}
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, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(pdf.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
json!({"entry_id": id, "upload_id": up}),
);
m.ok(
"books.post_entry",
@@ -325,11 +325,15 @@ fn fixture(m: &mut Mcp, p: &Places) {
"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", "file_name": "s.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
json!({"category": "statement", "upload_id": up}),
);
let _ = p;
}
@@ -624,19 +628,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.prop_map(Value::String)
.boxed();
}
if n.contains("base64") {
likely.extend(INLINE_FILES.map(String::from));
}
// A file's SHA-256: of one of the files above (so some calls carry a
// matching pair and go on past the check), or not.
// 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") {
use base64::Engine;
likely.extend(INLINE_FILES.iter().filter_map(|f| {
base64::engine::general_purpose::STANDARD
.decode(f)
.ok()
.map(|b| ledger_store::journal::sha256_hex(&b))
}));
likely.extend(
HOSTILE_FILES
.iter()
.map(|b| ledger_store::journal::sha256_hex(b)),
);
likely.push("0".repeat(64));
likely.push("not hex".into());
}
@@ -705,14 +704,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.boxed()
}
/// Files given inline (base64), hostile and not.
const INLINE_FILES: [&str; 6] = [
"JVBERi0xLjQ=",
"",
"!!!not base64",
"AAAA",
"NTcxLTkzLTI4NDY=",
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
/// 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
@@ -1169,12 +1168,11 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
"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, "file_name": "h.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgaHVnZQ==",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
json!({"entry_id": id, "upload_id": up}),
);
pinned(
&mut m,
@@ -1292,11 +1290,6 @@ fn hostile_resource_uris_never_stop_the_server() {
reopens(m.into_inner(), &p, "the resources");
}
fn b64(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The arguments, cut for a failure message.
fn truncated(v: &Value) -> String {
let s = v.to_string();
+30 -25
View File
@@ -15,25 +15,15 @@ mod common;
const CLIENTS: usize = 6;
const ENTRIES: usize = 12;
fn b64(bytes: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
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() {
if i <= c.len() {
out.push(T[((n >> shift) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
/// Draft a `cents` entry tagged `tag` on `c` (a session with `token`, of
/// the server whose `run/` folder is `run`), with a receipt uploaded for
/// it as an agent uploads one, if `with_receipt`.
async fn draft(
(c, run, token): (&Client, &std::path::Path, &str),
tag: &str,
cents: u64,
with_receipt: bool,
) -> u64 {
let e = call(
c,
"books.draft_entry",
@@ -53,12 +43,19 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
.unwrap();
}
if with_receipt {
let upload = common::upload_on(
c,
run,
common::BOOK,
token,
tag.as_bytes(),
(&format!("{tag}.pdf"), "application/pdf", "receipt"),
)
.await;
call(
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
json!({"entry_id": id, "upload_id": upload}),
)
.await
.unwrap();
@@ -102,7 +99,15 @@ async fn agents_posting_at_once_leave_whole_books() {
// Drafts every client will race to post.
let mut contested = Vec::new();
for k in 0..ENTRIES {
contested.push(draft(&setup, &format!("contested {k}"), 100 + k as u64, true).await);
contested.push(
draft(
(&setup, &run, &token),
&format!("contested {k}"),
100 + k as u64,
true,
)
.await,
);
}
let mut tasks = Vec::new();
@@ -110,13 +115,13 @@ async fn agents_posting_at_once_leave_whole_books() {
let (run, token) = (run.clone(), token.clone());
let contested = contested.clone();
tasks.push(tokio::spawn(async move {
let c = connect(run, token).await;
let c = connect(run.clone(), token.clone()).await;
let (mut posted, mut refused, mut won) = (0usize, 0usize, 0usize);
for (k, &race) in contested.iter().enumerate() {
// A receipted entry posts; a bare one is refused.
let receipted = k % 3 != 0;
let id = draft(
&c,
(&c, &run, &token),
&format!("c{n} e{k}"),
(n * 1000 + k + 1) as u64,
receipted,
+131 -82
View File
@@ -50,8 +50,10 @@ async fn chart(c: &Client) {
}
}
/// 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> {
/// Draft a receipted $1.00 + n entry on `c`, a session with acme's books
/// with `token` (of the server whose `run/` folder is `run`), its receipt
/// uploaded as an agent uploads one; post it. Ok(entry id) or Err(why).
async fn post_one(c: &Client, (run, token): (&Path, &str), n: u64) -> Result<u64, String> {
let e = call(
c,
"books.draft_entry",
@@ -68,12 +70,37 @@ async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
)
.await?;
}
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let bytes = format!("receipt {n}");
let begun = call(
c,
"books.begin_upload",
common::upload_args(
bytes.as_bytes(),
&format!("r{n}.txt"),
"text/plain",
"receipt",
),
)
.await?;
let upload = begun["upload_id"].as_str().unwrap_or_default().to_string();
let put = common::mcp_http_builder_at(run)
.build()
.unwrap()
.put(format!("http://localhost/b/acme/u/{upload}"))
.bearer_auth(token)
.body(bytes.into_bytes())
.send()
.await
.map_err(|e| e.to_string())?;
if put.status().as_u16() != 200 {
return Err(put.text().await.unwrap_or_default());
}
call(
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
json!({"entry_id": id, "upload_id": upload}),
)
.await?;
call(
@@ -115,7 +142,9 @@ async fn every_book_is_served_on_its_own_route() {
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 id = post_one(&acme, (&home.run(), &home.token("acme")), 1)
.await
.unwrap();
let entry = call(&acme, "books.get_entry", json!({"entry_id": id}))
.await
.unwrap();
@@ -227,11 +256,12 @@ async fn closing_during_a_burst_of_posts_loses_nothing() {
let mut tasks = Vec::new();
for n in 0..4u64 {
let c = connect(&home, "acme").await.unwrap();
let (run, token) = (home.run(), home.token("acme"));
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 {
match post_one(&c, (&run, &token), n * 100_000 + k).await {
Ok(id) => posted.push(id),
Err(why) => return (posted, why),
}
@@ -1175,7 +1205,8 @@ async fn every_call_is_recorded_once_refusals_included() {
let admin_text = home.token("acme");
let full = connect(&home, "acme").await.unwrap();
chart(&full).await; // 3 ok
let id = post_one(&full, 7).await; // draft, 2 lines, post: ok or an error
// draft, 2 lines, begin_upload, attach, post: ok or an error
let id = post_one(&full, (&home.run(), &admin_text), 7).await;
let _ = id;
// A call that errs (bad arguments) and one refused by scope.
assert!(
@@ -1211,8 +1242,8 @@ async fn every_call_is_recorded_once_refusals_included() {
assert_eq!(seqs, sorted, "newest first");
let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect();
// Every tool call made so far, one record each: 3 accounts, the
// posting (draft, two lines, post: up to four), the failed lookup,
// the refusal.
// posting (draft, two lines, its receipt's upload and filing, post),
// the failed lookup, the refusal.
let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count();
assert_eq!(by("books.add_account"), 4);
assert_eq!(by("books.get_entry"), 1);
@@ -1794,8 +1825,9 @@ async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
assert_eq!(meta["file_name"], json!("big receipt.pdf"));
assert_eq!(meta["mime_type"], json!("application/pdf"));
assert_eq!(meta["size_bytes"], json!(bytes.len()));
assert!(meta.get("content_base64").is_none_or(Value::is_null));
// Too large to come back inline: the command that saves a copy.
assert!(meta.get("file").is_none_or(Value::is_null), "{meta}");
// Its bytes: handed over (D-46), the command that saves a copy, and
// the copy is the stored bytes.
let big = call(
&c,
"books.get_receipt",
@@ -1803,12 +1835,17 @@ async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
)
.await
.unwrap();
assert!(big.get("content_base64").is_none_or(Value::is_null));
assert!(big.get("content_base64").is_none(), "{big}");
let command = big["file"]["download"].as_str().unwrap();
assert!(
command.starts_with("wanderbooks download --book acme --token test ex_")
&& command.ends_with(" --to <a folder>"),
"{command}"
);
assert_eq!(big["file"]["file_name"], json!("big receipt.pdf"));
assert_eq!(
big["download"],
json!(format!(
"wanderbooks download --book acme --token test {att} --to <a folder>"
))
common::fetch_export(&home, "acme", &home.token("acme"), &big["file"]).await,
bytes
);
// The same bytes again: the attachment that holds them, no upload.
let again = call(
@@ -1943,11 +1980,19 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let csv = b"code,name,type,parent_code\n1000,Cash,asset,\n".to_vec();
let up = common::upload_on(
&c,
&home.run(),
"acme",
&home.token("acme"),
&csv,
("chart.csv", "text/csv", "document"),
)
.await;
let filed = call(
&c,
"books.file_document",
json!({"category": "import_source", "file_name": "chart.csv", "mime_type": "text/csv",
"content_base64": b64(&csv), "sha256": ledger_store::journal::sha256_hex(&csv)}),
json!({"category": "import_source", "upload_id": up}),
)
.await
.unwrap();
@@ -1997,65 +2042,56 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
assert!(server.close().success());
}
/// D-46: a file comes only by its upload. The inline arguments that once
/// carried one are refused as unknown, whatever else comes with them,
/// pointing to books.begin_upload; a filing tool given no upload is
/// refused; the name and type a call gives are checked as the upload's
/// are; and the file comes back only handed over, its download the
/// stored bytes.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bytes_given_inline_are_small_and_carry_their_hash() {
let home = Home::new("inline");
async fn a_file_comes_only_by_its_upload_and_goes_back_by_its_download() {
let home = Home::new("handles");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let small = pdf("small", 1000);
let sha = ledger_store::journal::sha256_hex(&small);
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&small,
("s.pdf", "application/pdf", "document"),
)
.await;
let file = |extra: Value| {
let mut args = json!({"category": "other", "file_name": "s.pdf",
"mime_type": "application/pdf"});
let mut args = json!({"category": "other"});
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
args
};
for (extra, why) in [
(json!({"content_base64": b64(&small)}), "sha256"),
(
json!({"content_base64": b64(&small), "sha256": ledger_store::journal::sha256_hex(b"x")}),
"lost on the way",
),
(
json!({"content_base64": "not base64!", "sha256": sha}),
"not valid base64",
),
(
json!({"content_base64": b64(&small), "sha256": sha, "upload_id": "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}),
"not both",
),
(json!({}), "missing"),
for extra in [
json!({"content_base64": "JVBERi0xLjQ="}),
json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha}),
json!({"sha256": sha, "upload_id": up}),
json!({"content_base64": "JVBERi0xLjQ=", "upload_id": up}),
] {
let e = call(&c, "books.file_document", file(extra))
let e = call(&c, "books.file_document", file(extra.clone()))
.await
.unwrap_err();
assert!(e.contains(why), "{why}: {e}");
assert!(
e.contains("unknown argument") && e.contains("books.begin_upload"),
"{extra}: {e}"
);
}
// At most 256 KiB inline (the call's own limit is 1 MiB of base64).
let over = pdf("over", 256 * 1024 + 1);
let e = call(
&c,
"books.file_document",
file(json!({"content_base64": b64(&over),
"sha256": ledger_store::journal::sha256_hex(&over)})),
)
.await
.unwrap_err();
assert!(e.contains("upload it with books.begin_upload"), "{e}");
// Text longer than the most bytes' base64 is refused unread.
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
let e = call(
&c,
"books.file_document",
file(json!({"content_base64": long, "sha256": sha})),
)
.await
.unwrap_err();
assert!(e.contains("inline is at most"), "{e}");
// A plain name and a real type.
let e = call(&c, "books.file_document", file(json!({})))
.await
.unwrap_err();
assert!(e.contains("upload_id"), "{e}");
// A plain name and a real type, given or the upload's.
for (name, mime, why) in [
("../escape.pdf", "application/pdf", "no folder"),
("a\\b.pdf", "application/pdf", "no folder"),
@@ -2068,8 +2104,7 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
let e = call(
&c,
"books.file_document",
json!({"category": "other", "file_name": name, "mime_type": mime,
"content_base64": b64(&small), "sha256": sha}),
json!({"category": "other", "file_name": name, "mime_type": mime, "upload_id": up}),
)
.await
.unwrap_err();
@@ -2078,20 +2113,15 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
let e = call(
&c,
"books.file_document",
file(json!({"file_name": "x".repeat(256), "content_base64": b64(&small), "sha256": sha})),
file(json!({"file_name": "x".repeat(256), "upload_id": up})),
)
.await
.unwrap_err();
assert!(e.contains("at most 255 bytes"), "{e}");
let at = pdf("at", 256 * 1024);
let filed = call(
&c,
"books.file_document",
file(json!({"content_base64": b64(&at),
"sha256": ledger_store::journal::sha256_hex(&at).to_uppercase()})),
)
.await
.unwrap();
// Every refusal left the upload: filed now, under its own name.
let filed = call(&c, "books.file_document", file(json!({"upload_id": up})))
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
let back = call(
&c,
@@ -2100,8 +2130,13 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
)
.await
.unwrap();
assert_eq!(back["content_base64"], json!(b64(&at)));
assert!(back.get("download").is_none_or(Value::is_null));
assert!(back.get("content_base64").is_none(), "{back}");
assert_eq!(back["file"]["file_name"], json!("s.pdf"));
assert_eq!(back["file"]["sha256"], json!(sha));
assert_eq!(
common::fetch_export(&home, "acme", &token, &back["file"]).await,
small
);
let e = call(
&c,
"books.get_receipt",
@@ -2122,12 +2157,19 @@ async fn a_download_is_the_stored_bytes_for_a_reader_and_is_recorded() {
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let bytes = pdf("down", 2000);
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&bytes,
("Résumé \"final\".pdf", "application/pdf", "document"),
)
.await;
let filed = call(
&c,
"books.file_document",
json!({"category": "other", "file_name": "Résumé \"final\".pdf",
"mime_type": "application/pdf", "content_base64": b64(&bytes),
"sha256": ledger_store::journal::sha256_hex(&bytes)}),
json!({"category": "other", "upload_id": up}),
)
.await
.unwrap();
@@ -2491,14 +2533,21 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
assert_eq!(records.len(), 7, "{log}");
assert!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5);
// Given back to be filed, it is refused: the server made it with TINs.
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&zip,
("p.zip", "application/zip", "document"),
)
.await;
let e = call(
&c,
"books.file_document",
json!({"category": "tax", "file_name": "p.zip", "mime_type": "application/zip",
"content_base64": b64(&zip), "sha256": ledger_store::journal::sha256_hex(&zip)}),
json!({"category": "tax", "upload_id": up}),
)
.await;
assert!(zip.len() <= 256 * 1024, "small enough to give inline");
let e = e.unwrap_err();
assert!(e.contains("TIN"), "{e}");
// Nothing loose in the book's folder: the export is in its own.
+327 -175
View File
@@ -85,6 +85,21 @@ impl Mcp {
assert!(file["expires_at"].is_string(), "{file}");
self.wire.fetch(file)
}
/// A file handed over, as text.
fn handed_text(&mut self, file: &Value) -> String {
String::from_utf8(self.handed(file)).expect("the file is text")
}
/// 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)
}
/// Upload a CSV for an import: the upload_id.
fn csv_upload(&mut self, csv: &str) -> String {
self.upload(csv.as_bytes(), "import.csv", "text/csv", "import")
}
}
#[test]
@@ -98,7 +113,7 @@ fn mcp_end_to_end() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -188,16 +203,10 @@ fn mcp_end_to_end() {
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Attach the receipt, then the confirmed post succeeds.
let png = base64_like(b"fake-png-bytes");
let png = mcp.upload(b"fake-png-bytes", "rent.png", "image/png", "receipt");
let attached = mcp.call(
"books.attach_receipt",
json!({
"entry_id": entry_id,
"file_name": "rent.png",
"mime_type": "image/png",
"content_base64": png,
"sha256": sha(b"fake-png-bytes"),
}),
json!({"entry_id": entry_id, "upload_id": png}),
);
assert_eq!(attached["deduplicated"], false);
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
@@ -243,7 +252,7 @@ fn mcp_end_to_end() {
"books.export",
json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert!(export["content"].as_str().unwrap().contains("January rent"));
assert!(mcp.handed_text(&export["file"]).contains("January rent"));
// Reversal: preview then post; the book still balances.
let rev_preview = mcp.call(
@@ -270,7 +279,7 @@ fn mcp_assets_end_to_end() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -300,7 +309,7 @@ fn mcp_assets_end_to_end() {
"useful_life_months": 60,
"cost_account": "1500",
"accum_depr_account": "1501",
"expense_account": "6100",
"expense_account": "6100"
}),
);
let asset_id = reg["asset_id"].as_u64().unwrap();
@@ -358,7 +367,7 @@ fn mcp_assets_end_to_end() {
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"gain_loss_account": "4900"
}),
);
assert_eq!(preview["status"], "preview");
@@ -376,7 +385,7 @@ fn mcp_assets_end_to_end() {
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
"confirmed": true
}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
@@ -384,14 +393,17 @@ fn mcp_assets_end_to_end() {
assert_eq!(err_json["remedy_tool"], "books.file_document");
// File the bill of sale, then confirm with its attachment id.
let up1 = mcp.upload(
b"fake bill of sale",
"bill-of-sale.pdf",
"application/pdf",
"document",
);
let bill = mcp.call(
"books.file_document",
json!({
"category": "legal",
"file_name": "bill-of-sale.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"),
"sha256": sha(b"fake bill of sale"),
"upload_id": up1
}),
);
let aid = bill["attachment_id"].as_u64().unwrap();
@@ -404,7 +416,7 @@ fn mcp_assets_end_to_end() {
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
"receipt_attachment_ids": [aid],
"receipt_attachment_ids": [aid]
}),
);
assert_eq!(posted["status"], "posted");
@@ -440,7 +452,7 @@ fn mcp_receipt_policy_rejects_split_entries() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -532,7 +544,7 @@ fn mcp_receipt_corrections() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -556,11 +568,10 @@ fn mcp_receipt_corrections() {
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
);
let up2 = mcp.upload(receipt, "receipt.eml", "message/rfc822", "receipt");
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
"content_base64": base64_like(receipt),
"sha256": sha(receipt)}),
json!({"entry_id": id, "upload_id": up2}),
);
let posted = mcp.call(
"books.post_entry",
@@ -584,11 +595,10 @@ fn mcp_receipt_corrections() {
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Replace: attach the PDF first, then unlink the .eml.
let up3 = mcp.upload(b"pdf-bytes", "receipt.pdf", "application/pdf", "receipt");
let pdf = mcp.call(
"books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"pdf-bytes"),
"sha256": sha(b"pdf-bytes")}),
json!({"entry_id": big, "upload_id": up3}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -669,7 +679,7 @@ fn mcp_resources() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -722,7 +732,7 @@ fn init(mcp: &mut Mcp) -> Value {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -754,11 +764,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
);
let w9_upload = mcp.upload(w9, "w9.pdf", "application/pdf", "w9");
mcp.call(
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn",
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
"tin": "291-12-2846", "tin_type": "ssn", "w9_upload_id": w9_upload}),
);
// Each comes back by its download.
let got = mcp.call("books.get_w9", json!({"party": "Acme"}));
@@ -779,19 +789,13 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
// filed.
let journal = books.journal();
for (n, bytes) in handed_over.iter().enumerate() {
// One upload, refused by each tool and so left for the next.
let up = mcp.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
),
("books.attach_receipt", json!({"upload_id": up})),
(
"books.file_document",
json!({"category": "statement", "file_name": "statement.csv",
"mime_type": "text/csv",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
json!({"category": "statement", "upload_id": up}),
),
] {
let why = mcp.call_err(tool, args);
@@ -808,10 +812,10 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
assert!(plain["warning"].is_null(), "{plain}");
assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
let bytes = mcp.handed(&plain["csv"]);
let up6 = mcp.upload(&bytes, "plain.csv", "text/csv", "document");
mcp.call(
"books.file_document",
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv",
"content_base64": base64_like(&bytes), "sha256": sha(&bytes)}),
json!({"category": "statement", "upload_id": up6}),
);
}
@@ -840,7 +844,7 @@ fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -902,7 +906,7 @@ fn digest_bare_reads_once(attempt: u32) -> bool {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -972,7 +976,7 @@ fn handbook_tool_and_resources() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
let instructions = init["result"]["instructions"].as_str().unwrap();
@@ -1057,30 +1061,6 @@ fn handbook_tool_and_resources() {
assert!(resp.get("error").is_some());
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let mut n: u32 = 0;
for (i, &b) in chunk.iter().enumerate() {
n |= (b as u32) << (16 - 8 * i);
}
let pad = 3 - chunk.len();
for i in 0..4 - pad {
out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
for _ in 0..pad {
out.push('=');
}
}
out
}
#[test]
fn mcp_import_export() {
let books = Books::new("import_export");
@@ -1090,30 +1070,37 @@ fn mcp_import_export() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
let chart = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}),
);
let up1 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n");
let chart = mcp.call("books.import_chart_csv", json!({"upload_id": up1}));
assert_eq!(chart["count"], 3);
// The IIF is plain ASCII text with CR LF line endings, inline; what it
// writes differently from the books is in warnings (rules.md B-34).
// The IIF is plain ASCII text with CR LF line endings, handed over
// (D-46); what it writes differently from the books is in warnings
// (rules.md B-34).
let iif = mcp.call(
"books.export",
json!({"format": "iif", "report": "journal"}),
);
let text = iif["content"].as_str().unwrap();
let text = &mcp.handed_text(&iif["file"]);
assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n"));
assert!(
text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"),
"{text}"
);
assert!(iif.get("content_encoding").is_none(), "{iif}");
assert!(iif.get("content").is_none(), "{iif}");
assert_eq!(
iif["file"]["file_name"]
.as_str()
.unwrap()
.rsplit('.')
.next(),
Some("iif")
);
assert_eq!(
iif["warnings"],
json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""])
@@ -1123,14 +1110,15 @@ fn mcp_import_export() {
"books.export",
json!({"format": "xlsx", "report": "trial_balance"}),
);
assert_eq!(xlsx["content_encoding"], "base64");
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
assert_eq!(
xlsx["file"]["mime_type"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
assert!(mcp.handed(&xlsx["file"]).starts_with(b"PK"), "a zip");
}
#[test]
fn mcp_statement_pdfs_and_cpa_package() {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD;
let books = Books::new("statement-pdfs");
let mut mcp = Mcp::spawn(&books);
mcp.request(
@@ -1138,14 +1126,12 @@ fn mcp_statement_pdfs_and_cpa_package() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
);
let up2 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up2}));
for (desc, debit, credit, amount) in [
("Owner contribution", "1000", "3000", "1000.00"),
("Domain", "6000", "1000", "12.00"),
@@ -1163,11 +1149,10 @@ fn mcp_statement_pdfs_and_cpa_package() {
"books.add_line",
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
);
let up = mcp.upload(desc.as_bytes(), "r.pdf", "application/pdf", "receipt");
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(desc.as_bytes()),
"sha256": sha(desc.as_bytes())}),
json!({"entry_id": id, "upload_id": up}),
);
mcp.call(
"books.post_entry",
@@ -1187,8 +1172,8 @@ fn mcp_statement_pdfs_and_cpa_package() {
.unwrap()
.extend(extra.as_object().unwrap().clone());
let out = mcp.call("books.export", args);
assert_eq!(out["content_encoding"], "base64");
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap();
assert_eq!(out["file"]["mime_type"], "application/pdf");
let bytes = mcp.handed(&out["file"]);
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
}
// The cash flow statement reconciles to named cash accounts: required.
@@ -1297,14 +1282,12 @@ fn mcp_period_statements() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
);
let up3 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up3}));
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call(
"books.draft_entry",
@@ -1328,11 +1311,10 @@ fn mcp_period_statements() {
json!({"entry_id": id, "confirmed": true}),
);
assert!(err.contains("5.00"), "{err}");
let up7 = mcp.upload(b"r", "r.pdf", "application/pdf", "receipt");
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"r"),
"sha256": sha(b"r")}),
json!({"entry_id": id, "upload_id": up7}),
);
mcp.call(
"books.post_entry",
@@ -1388,7 +1370,7 @@ fn mcp_period_statements() {
"books.export",
json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}),
);
let gl: serde_json::Value = serde_json::from_str(gl["content"].as_str().unwrap()).unwrap();
let gl: serde_json::Value = serde_json::from_str(&mcp.handed_text(&gl["file"])).unwrap();
let cash = &gl["accounts"][0];
assert_eq!(cash["beginning_balance"], "1004.00");
assert_eq!(cash["postings"][0]["split"], "6000 Software");
@@ -1403,7 +1385,7 @@ fn mcp_period_statements() {
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][0]["name"], "Owner");
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "3.50");
@@ -1412,7 +1394,7 @@ fn mcp_period_statements() {
"books.export",
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
);
assert_eq!(pdf["content_encoding"], "base64");
assert!(mcp.handed(&pdf["file"]).starts_with(b"%PDF"));
// Closing January: a one-member LLC closes into the member's capital
// account, not Retained Earnings. The preview is exactly what posts.
@@ -1442,7 +1424,7 @@ fn mcp_period_statements() {
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00");
@@ -1488,17 +1470,15 @@ fn mcp_period_statements() {
"books.export",
json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}),
);
let ts: serde_json::Value = serde_json::from_str(ts["content"].as_str().unwrap()).unwrap();
let ts: serde_json::Value = serde_json::from_str(&mcp.handed_text(&ts["file"])).unwrap();
assert_eq!(ts["form"], "Form 1065");
assert_eq!(ts["income"][0]["book"], "6.50");
assert_eq!(ts["deductions"][0]["category"], "other_deductions");
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
assert_eq!(ts["tax_net"], "3.50");
// A chart import validates categories before creating anything.
let err = mcp.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
);
let up4 = mcp.csv_upload("code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n");
let err = mcp.call_err("books.import_chart_csv", json!({"upload_id": up4}));
assert!(err.contains("4100"), "{err}");
let chart = mcp.call("books.chart", json!({}));
assert!(
@@ -1555,7 +1535,8 @@ fn mcp_partnership_from_template() {
);
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
let up5 = mcp.csv_upload(t["csv"].as_str().unwrap());
mcp.call("books.import_chart_csv", json!({"upload_id": up5}));
mcp.call(
"books.set_setting",
json!({"changes": settings, "decided_by": "Ann"}),
@@ -1589,7 +1570,7 @@ fn mcp_partnership_from_template() {
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][1]["distributions"], "50.00");
assert_eq!(me["members"][1]["ending"], "1150.00");
@@ -1662,7 +1643,8 @@ fn close_period_shows_each_members_running_split() {
mcp
};
let mut mcp = spawn(&books);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
let up6 = mcp.csv_upload(t["csv"].as_str().unwrap());
mcp.call("books.import_chart_csv", json!({"upload_id": up6}));
mcp.call(
"books.set_setting",
json!({"changes": t["settings"], "decided_by": "Ann"}),
@@ -1759,7 +1741,7 @@ fn close_period_shows_each_members_running_split() {
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][0]["ending"], "0.01");
assert_eq!(me["members"][1]["ending"], "0.01");
}
@@ -1934,7 +1916,7 @@ fn mcp_schedules_and_recurring() {
"months": 3,
"balance_account": "1300",
"pl_account": "6300",
"convention": "monthly_even",
"convention": "monthly_even"
}),
);
assert_eq!(s["convention"], "monthly_even");
@@ -1950,7 +1932,7 @@ fn mcp_schedules_and_recurring() {
json!({
"kind": "prepaid_expense", "description": "x", "total": "10",
"start": "2026-11-01", "months": 2,
"balance_account": "6300", "pl_account": "6300",
"balance_account": "6300", "pl_account": "6300"
}),
);
assert!(err.contains("Asset"), "{err}");
@@ -1989,7 +1971,7 @@ fn mcp_schedules_and_recurring() {
"lines": [
{"account": "6600", "side": "debit", "amount": "2000.00"},
{"account": "1000", "side": "credit", "amount": "2000.00"},
],
]
}),
);
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
@@ -2025,7 +2007,7 @@ fn mcp_payout_gross_up() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2082,14 +2064,17 @@ fn mcp_payout_gross_up() {
// The statement is the receipt; then it posts like any entry.
let entry_id = out["entry_id"].as_u64().unwrap();
let up8 = mcp.upload(
b"%PDF-1.4 statement",
"payout.pdf",
"application/pdf",
"receipt",
);
mcp.call(
"books.attach_receipt",
json!({
"entry_id": entry_id,
"file_name": "payout.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 statement"),
"sha256": sha(b"%PDF-1.4 statement"),
"upload_id": up8
}),
);
mcp.call(
@@ -2178,14 +2163,17 @@ fn mcp_vendor_1099() {
json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}),
);
}
let up9 = mcp.upload(
b"%PDF-1.4 invoice",
"invoice.pdf",
"application/pdf",
"receipt",
);
mcp.call(
"books.attach_receipt",
json!({
"entry_id": id,
"file_name": "invoice.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 invoice"),
"sha256": sha(b"%PDF-1.4 invoice"),
"upload_id": up9
}),
);
mcp.call(
@@ -2283,7 +2271,7 @@ fn mcp_receipts_always_required_when_threshold_is_zero() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
let instructions = init["result"]["instructions"].as_str().unwrap();
@@ -2341,7 +2329,7 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2440,7 +2428,7 @@ fn cash_flow_refuses_contradictory_roles() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2487,7 +2475,7 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2506,14 +2494,14 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
// A child of another type than its parent.
"code,name,type,parent_code\n1000,Cash,asset,\n4000,Sales,revenue,1000\n",
] {
let err = mcp.call_err("books.import_chart_csv", json!({"csv_text": bad}));
let up7 = mcp.csv_upload(bad);
let err = mcp.call_err("books.import_chart_csv", json!({"upload_id": up7}));
assert!(!err.is_empty());
assert_eq!(count(&mut mcp), before, "half-imported: {bad}");
}
let ok = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n"}),
);
let up8 =
mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n");
let ok = mcp.call("books.import_chart_csv", json!({"upload_id": up8}));
assert_eq!(ok["count"], 2);
}
@@ -2529,7 +2517,7 @@ fn bank_import_reads_dates_rules_and_files_whole() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2544,9 +2532,10 @@ fn bank_import_reads_dates_rules_and_files_whole() {
json!({"code": code, "name": name, "account_type": typ}),
);
}
let base = |csv: &str| {
// The CSV's upload: one refused is left for the next call.
let base = |upload: &str| {
json!({
"csv_text": csv,
"upload_id": upload,
"bank_account_code": "1000",
"default_offset_code": "6000",
"rules": {"coffee": "6100", "coffee beans": "6200"},
@@ -2554,18 +2543,17 @@ fn bank_import_reads_dates_rules_and_files_whole() {
};
// 3/4/2026 reads both ways and nothing in the file settles it.
let ambiguous = "Date,Description,Amount\n3/4/2026,Coffee shop,-4.50\n";
let err = mcp.call_err("books.import_bank_csv", base(ambiguous));
let up = mcp.csv_upload(ambiguous);
let err = mcp.call_err("books.import_bank_csv", base(&up));
assert!(err.contains("either way"), "{err}");
let mut args = base(ambiguous);
let mut args = base(&up);
args["date_order"] = json!("day_first");
let out = mcp.call("books.import_bank_csv", args);
assert_eq!(out["imported"][0]["date"], "2026-04-03");
// Both patterns match "Coffee beans": the longer wins, every time.
for _ in 0..5 {
let out = mcp.call(
"books.import_bank_csv",
base("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n"),
);
let up = mcp.csv_upload("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n");
let out = mcp.call("books.import_bank_csv", base(&up));
assert_eq!(out["imported"][0]["offset_account"], "6200");
}
// A transaction dated in a closed period refuses the file: nothing of
@@ -2578,12 +2566,10 @@ fn bank_import_reads_dates_rules_and_files_whole() {
"books.close_period",
json!({"period_id": p["period_id"], "confirmed": true}),
);
let err = mcp.call_err(
"books.import_bank_csv",
base(
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
),
let up = mcp.csv_upload(
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
);
let err = mcp.call_err("books.import_bank_csv", base(&up));
assert!(err.contains("closed"), "{err}");
let journal = String::from_utf8(books.journal()).unwrap();
assert!(
@@ -2608,7 +2594,7 @@ fn renumbering_an_account_keeps_its_history() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2690,7 +2676,7 @@ fn renumbering_an_account_keeps_its_history() {
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}),
);
mcp.notify("notifications/initialized");
@@ -2756,10 +2742,8 @@ fn one_default_period_everywhere_defect_16() {
&books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let up9 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up9}));
let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00");
let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00");
let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00");
@@ -2786,7 +2770,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export",
json!({"report": report, "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
assert_eq!(pair(&v), want, "{report}");
}
// The general journal: the period's entries, in json and csv alike.
@@ -2794,7 +2778,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export",
json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
let ids: Vec<u64> = v
.as_array()
.unwrap()
@@ -2806,7 +2790,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export",
json!({"report": "journal", "format": "csv", "end": "2026-03-31"}),
);
let rows = csv["content"].as_str().unwrap().lines().count();
let rows = mcp.handed_text(&csv["file"]).lines().count();
assert_eq!(rows, 1 + 4, "header and two entries of two lines");
// An inverted range is refused by the journal too (review R1).
let err = mcp.call_err(
@@ -2836,7 +2820,7 @@ fn one_default_period_everywhere_defect_16() {
];
for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call("books.export", json!({"report": report, "format": "json"}));
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
got.push(pair(&v));
}
let after = today();
@@ -2886,10 +2870,8 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
&books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let up10 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up10}));
let entries = [
("2025-03-15", "1000", "3000", "500.00"),
("2025-09-10", "1000", "4000", "120.00"),
@@ -2906,7 +2888,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
json!({"report": "general_journal", "format": "json"}),
);
let after = today();
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
let journal: Vec<u64> = v
.as_array()
.unwrap()
@@ -2939,7 +2921,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
let iif = mcp.call("books.export", json!({"report": report, "format": "iif"}));
assert_eq!(iif["report"], report.to_lowercase());
assert_eq!(
iif_docnums(iif["content"].as_str().unwrap()),
iif_docnums(&mcp.handed_text(&iif["file"])),
every,
"{report}"
);
@@ -2986,10 +2968,8 @@ fn rename_account_end_to_end_defect_15() {
&books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n"}),
);
let up11 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up11}));
post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00");
post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00");
let verified = mcp.call("books.verify", json!({"blobs": false}));
@@ -3045,14 +3025,14 @@ fn rename_account_end_to_end_defect_15() {
"books.export",
json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}),
);
let pl: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let pl: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions");
assert_eq!(pl["expense_accounts"][0]["amount"], "29.00");
let iif = mcp.call(
"books.export",
json!({"report": "journal", "format": "iif"}),
);
let iif = iif["content"].as_str().unwrap();
let iif = &mcp.handed_text(&iif["file"]);
assert!(iif.contains("Operating:Software subscriptions"), "{iif}");
// A renamed parent: its children's full names follow, and the note
// says so.
@@ -3112,10 +3092,8 @@ fn rename_says_what_the_new_name_moves_defect_15() {
&books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n"}),
);
let up12 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n");
mcp.call("books.import_chart_csv", json!({"upload_id": up12}));
mcp.call(
"books.set_convention",
json!({"id": "software-note", "kind": "note", "title": "Subscriptions",
@@ -3258,3 +3236,177 @@ fn mcp_daily_schedule() {
);
assert!(err.contains("daily or monthly_even"), "{err}");
}
/// D-46: files pass only by handle. The five tools that file a file take
/// an upload and no other argument (their schemas close, and none names
/// an inline field); an inline argument is refused as unknown before the
/// tool runs, pointing to books.begin_upload, storing nothing; and every
/// file a tool once gave inline (books.export in each format,
/// books.asset_labels, books.get_receipt view bytes) comes back handed
/// over, its download the very bytes, with the size and SHA-256 it
/// states, and no bytes in the answer.
#[test]
fn files_pass_only_by_handle_in_and_out() {
let books = Books::with(
"handles",
"USD",
Some("[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n"),
);
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
// The schemas: closed, an upload, nothing inline.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
let tool = |name: &str| {
tools
.iter()
.find(|t| t["name"] == name)
.unwrap_or_else(|| panic!("{name} listed"))
.clone()
};
for (name, upload) in [
("books.attach_receipt", "upload_id"),
("books.file_document", "upload_id"),
("books.set_w9", "w9_upload_id"),
("books.import_bank_csv", "upload_id"),
("books.import_chart_csv", "upload_id"),
] {
let input = &tool(name)["inputSchema"];
assert_eq!(input["additionalProperties"], json!(false), "{name}");
let props = input["properties"].as_object().unwrap();
assert!(props.contains_key(upload), "{name}");
for gone in [
"content_base64",
"sha256",
"csv_text",
"w9_content_base64",
"w9_sha256",
] {
assert!(!props.contains_key(gone), "{name} takes {gone}");
}
}
for name in ["books.export", "books.asset_labels", "books.get_receipt"] {
let out = tool(name)["outputSchema"].to_string();
assert!(!out.contains("content_base64"), "{name}: {out}");
assert!(!out.contains("content_encoding"), "{name}: {out}");
assert!(out.contains("\"file\""), "{name}: {out}");
}
let chart = mcp.csv_upload(
"code,name,type,parent_code\n1000,Cash,asset,\n1500,Equipment,asset,\n\
1510,Accumulated Depreciation,asset,\n3000,Capital,equity,\n\
6500,Depreciation,expense,\n",
);
mcp.call("books.import_chart_csv", json!({"upload_id": chart}));
// In: an inline file is refused as unknown, storing nothing.
let journal = books.journal();
for (name, args) in [
(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQ=",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4")}),
),
(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1001,Petty,asset,\n"}),
),
] {
let resp = mcp.request_raw("tools/call", json!({"name": name, "arguments": args}));
let why = resp["error"]["message"].as_str().unwrap_or_default();
assert!(
why.contains("unknown argument") && why.contains("books.begin_upload"),
"{name}: {resp}"
);
}
assert_eq!(books.journal(), journal, "an inline file stored something");
// A posted entry with its receipt, and an asset with its tag.
let receipt = b"%PDF-1.4 a receipt for the laptop";
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-01-02", "description": "Laptop"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("1500", "debit"), ("3000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "1200.00"}),
);
}
let up = mcp.upload(receipt, "laptop.pdf", "application/pdf", "receipt");
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "upload_id": up}),
)["attachment_id"]
.clone();
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "no_party_reason": "a member's contribution"}),
);
mcp.call(
"books.register_asset",
json!({"description": "Laptop", "in_service_date": "2026-01-02", "cost": "1200.00",
"useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500"}),
);
// Out: each file handed over, its download the bytes it states.
let handed = |mcp: &mut Mcp, out: &Value, mime: &str| -> Vec<u8> {
for inline in ["content", "content_base64", "content_encoding"] {
assert!(out.get(inline).is_none(), "{inline} in {out}");
}
let file = &out["file"];
assert_eq!(file["mime_type"], mime, "{out}");
assert!(
file["download"]
.as_str()
.unwrap()
.starts_with("wanderbooks download --book acme"),
"{file}"
);
let bytes = mcp.handed(file);
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes))
);
assert_eq!(file["size_bytes"], json!(bytes.len()));
bytes
};
for (format, report, mime, starts) in [
("csv", "general_journal", "text/csv", &b"entry_id,"[..]),
("json", "trial_balance", "application/json", b"{"),
(
"xlsx",
"balance_sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
b"PK",
),
("pdf", "profit_and_loss", "application/pdf", b"%PDF"),
("iif", "general_journal", "text/plain", b"!"),
] {
let out = mcp.call("books.export", json!({"format": format, "report": report}));
let bytes = handed(&mut mcp, &out, mime);
assert!(
bytes.starts_with(starts),
"{format} {report}: {:?}",
String::from_utf8_lossy(&bytes[..bytes.len().min(40)])
);
assert!(
out["file"]["file_name"]
.as_str()
.unwrap()
.ends_with(&format!(".{format}")),
"{out}"
);
}
let labels = mcp.call("books.asset_labels", json!({}));
assert_eq!(labels["tags"].as_array().unwrap().len(), 1, "{labels}");
assert!(handed(&mut mcp, &labels, "application/pdf").starts_with(b"%PDF"));
let back = mcp.call(
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
);
assert_eq!(handed(&mut mcp, &back, "application/pdf"), receipt);
assert_eq!(back["file"]["sha256"], back["sha256"]);
assert_eq!(back["file"]["file_name"], "laptop.pdf");
let meta = mcp.call("books.get_receipt", json!({"attachment_id": att}));
assert!(meta.get("file").is_none(), "{meta}");
}
+56 -8
View File
@@ -301,13 +301,19 @@ struct Agent {
next: u64,
/// Notifications received.
notes: Vec<Value>,
/// How it runs the commands it is given (`wanderbooks upload`), as
/// [`Home::cmd`] runs them, and the folder its files are in: the
/// program, the data root, the home.
commands: Option<(PathBuf, PathBuf, PathBuf)>,
}
impl Agent {
fn start(home: &Home, book: &str) -> Self {
let mut c = home.cmd("mcp");
c.args(["--book", book]);
Self::from(c)
let mut a = Self::from(c);
a.commands = Some((home.exe.clone(), home.root(), home.dir.path().to_path_buf()));
a
}
fn from(c: Command) -> Self {
@@ -315,9 +321,50 @@ impl Agent {
run: Running::start(c),
next: 1,
notes: Vec::new(),
commands: None,
}
}
/// Send `bytes` as `name` as an agent does (D-46): books.begin_upload,
/// the file written in the home, and the command it returns run on
/// it. The upload_id.
fn upload(&mut self, bytes: &[u8], name: &str) -> Result<String, String> {
let (exe, root, home) = self.commands.clone().expect("an agent Agent::start made");
let begun = self.call(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": "text/plain", "file_name": name, "purpose": "receipt"}),
)?;
let command = begun["command"]
.as_str()
.ok_or_else(|| format!("no upload begun: {begun}"))?;
let words: Vec<&str> = command
.strip_suffix(" <the file's path>")
.ok_or_else(|| command.to_string())?
.split(' ')
.collect();
let path = home.join(format!("upload-{}", begun["upload_id"].as_str().unwrap()));
std::fs::write(&path, bytes).unwrap();
let out = Command::new(exe)
.arg(words[1])
.arg("--root")
.arg(root)
.args(["--test-knobs", KNOBS])
.args(&words[2..])
.arg(&path)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", &home)
.stdin(Stdio::null())
.output()
.unwrap();
let _ = std::fs::remove_file(&path);
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).to_string());
}
Ok(begun["upload_id"].as_str().unwrap().to_string())
}
fn send(&mut self, message: Value) {
let stdin = self.run.child.stdin.as_mut().unwrap();
writeln!(stdin, "{message}").unwrap();
@@ -405,11 +452,12 @@ fn post_one(a: &mut Agent, n: u64) -> Result<u64, String> {
json!({"entry_id": id, "account": account, "side": side, "amount": "1.00"}),
)?;
}
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let upload = a.upload(format!("receipt {n}").as_bytes(), &format!("r{n}.txt"))?;
a.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
json!({"entry_id": id, "upload_id": upload}),
)?;
a.call(
"books.post_entry",
@@ -2257,15 +2305,15 @@ fn an_agent_sends_and_saves_files_with_the_commands_it_is_given() {
)
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
// Too big to come back inline: the command saves a copy, beside any
// file of its name, never over it.
// Handed over (D-46): the command saves a copy, beside any file of
// its name, never over it.
let back = a
.call(
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
)
.unwrap();
let download = back["download"].as_str().unwrap().to_string();
let download = back["file"]["download"].as_str().unwrap().to_string();
for n in 1..=2 {
let out = run_given(
&home,
@@ -2348,7 +2396,7 @@ fn the_commands_given_run_as_the_agents_own_token() {
json!({"attachment_id": filed["attachment_id"], "view": "bytes"}),
)
.unwrap();
let download = back["download"].as_str().unwrap().to_string();
let download = back["file"]["download"].as_str().unwrap().to_string();
assert!(download.contains(" --token bookkeeper "), "{download}");
let out = run_given(
&home,
+137 -161
View File
@@ -12,7 +12,6 @@
//! folder of a data root of the test's own; then the store is closed and
//! the server started on that root.
use base64::Engine as _;
use common::{Place, Wire};
use ledger_core::{
AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital,
@@ -148,25 +147,21 @@ impl Mcp {
}
}
/// `books.export`, returning the content (base64-decoded when the
/// server says it is base64).
/// `books.export`'s file: handed over (D-46), fetched by its
/// download, never its bytes in the answer.
fn export(&mut self, args: Value) -> Vec<u8> {
let out = self.ok("books.export", args);
let content = out["content"].as_str().unwrap();
if out["content_encoding"] == "base64" {
b64d(content)
} else {
content.as_bytes().to_vec()
}
let out = self.ok("books.export", args.clone());
assert!(out.get("content").is_none(), "{args} came back inline");
self.handed(&out["file"])
}
fn export_text(&mut self, args: Value) -> String {
let out = self.ok("books.export", args.clone());
assert!(
out.get("content_encoding").is_none_or(|e| e.is_null()),
"text export {args} came back encoded"
);
out["content"].as_str().unwrap().to_string()
String::from_utf8(self.export(args)).expect("a text export")
}
/// 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)
}
/// The viewer command the last `books.show` wrote, in the books' own
@@ -185,16 +180,6 @@ impl Mcp {
// ---------------------------------------------------------------- helpers
fn b64e(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn b64d(s: &str) -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode(s)
.expect("valid base64")
}
fn sha256(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
@@ -1440,7 +1425,8 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
"books.export",
json!({"format": "iif", "report": "journal", "as_of": END}),
);
assert!(out.get("content_encoding").is_none(), "plain text: {out}");
assert!(out.get("content").is_none(), "handed over: {out}");
assert_eq!(out["file"]["mime_type"], "text/plain");
let lunch = ledger
.posted_entries()
.find(|e| e.description().starts_with("Lunch"))
@@ -1466,7 +1452,7 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
),
];
assert_eq!(out["warnings"], json!(warnings));
let iif = out["content"].as_str().unwrap();
let iif = &String::from_utf8(m.handed(&out["file"])).unwrap();
assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n']));
let mut types: HashMap<String, String> = HashMap::new();
@@ -2081,10 +2067,10 @@ fn post_contribution(m: &mut Mcp) -> u64 {
json!({"entry_id": e, "account": a, "side": side, "amount": "10000.00"}),
);
}
let up = m.upload(b"contribution", "c.txt", "text/plain", "receipt");
m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain",
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
json!({"entry_id": e, "upload_id": up}),
);
m.ok(
"books.post_entry",
@@ -2739,7 +2725,7 @@ fn resources_read_back_as_the_matching_tools() {
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(b64d(full["content_base64"].as_str().unwrap()), bytes);
assert_eq!(m.handed(&full["file"]), bytes);
}
let r = m.rpc("resources/read", json!({"uri": "books://nope"}));
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}"));
@@ -2764,29 +2750,38 @@ fn receipts_and_documents_round_trip() {
let e = post_contribution(&mut m);
let first: &[u8] = b"%PDF-1.4 first";
let second: &[u8] = b"\x89PNG second \x00\xff";
let up = m.upload(first, "first.pdf", "application/pdf", "receipt");
let a = m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
"content_base64": b64e(first), "sha256": sha256(first),
json!({"entry_id": e, "upload_id": up,
"description": "Receipt ID 1007; Paid from: Quillmere"}),
);
let up = m.upload(second, "oa.png", "image/png", "document");
let b = m.ok(
"books.file_document",
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png",
"content_base64": b64e(second), "sha256": sha256(second),
"description": "Operating agreement"}),
json!({"category": "legal", "upload_id": up, "description": "Operating agreement"}),
);
let up = m.upload(
b"EIN letter bytes",
"ein-letter.pdf",
"application/pdf",
"document",
);
let c = m.ok(
"books.file_document",
json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf",
"content_base64": b64e(b"EIN letter bytes"), "sha256": sha256(b"EIN letter bytes"),
"description": "IRS CP 575"}),
json!({"category": "tax", "upload_id": up, "description": "IRS CP 575"}),
);
// The name and type a call gives win over the upload's.
let up = m.upload(
b"a scanned receipt",
"scan.bin",
"application/octet-stream",
"receipt",
);
let r = m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png",
"content_base64": b64e(b"a scanned receipt"),
"sha256": sha256(b"a scanned receipt"), "description": "scan"}),
json!({"entry_id": e, "upload_id": up, "file_name": "renamed.png",
"mime_type": "image/png", "description": "scan"}),
);
// (result, bytes, file name, MIME type, entry, description)
@@ -2830,23 +2825,20 @@ fn receipts_and_documents_round_trip() {
let id = out["attachment_id"].as_u64().unwrap();
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
assert!(
meta.get("content_base64").is_none(),
"{name}: meta, no bytes"
);
assert!(meta.get("file").is_none(), "{name}: meta, no file");
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(got["attachment_id"], json!(id));
assert_eq!(
b64d(got["content_base64"].as_str().unwrap()),
bytes,
"{name}"
);
// Handed over (D-46): its download is the bytes, under its name.
assert!(got.get("content_base64").is_none(), "{name}: {got}");
assert_eq!(m.handed(&got["file"]), bytes, "{name}");
assert_eq!(got["file"]["file_name"], name);
assert_eq!(got["file"]["mime_type"], mime);
let mut without = got.clone();
without.as_object_mut().unwrap().remove("content_base64");
assert_eq!(without, meta, "{name}: view bytes is meta and the bytes");
without.as_object_mut().unwrap().remove("file");
assert_eq!(without, meta, "{name}: view bytes is meta and the file");
assert_eq!(got["sha256"], sha256(bytes));
assert_eq!(got["file_name"], name);
assert_eq!(got["mime_type"], mime);
@@ -2882,31 +2874,27 @@ fn receipts_and_documents_round_trip() {
}
}
/// 256 KiB is the most a file may be given inline (plan 2.1): exactly
/// the limit is accepted by books.attach_receipt and books.file_document
/// and stored whole, one byte more is refused by both, pointing at
/// books.begin_upload, and nothing is stored. Kills: the limit comparison
/// in take_file.
/// No file is given inline any more (D-46): one past the old inline limit
/// (256 KiB) is filed by upload through books.attach_receipt and
/// books.file_document and stored whole, and comes back whole by its
/// download; the inline arguments are refused by both as unknown,
/// pointing at books.begin_upload, and nothing is stored.
#[test]
fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
const LIMIT: usize = 256 * 1024;
fn files_past_the_old_inline_limit_go_by_upload_and_come_back_by_download() {
const OLD_LIMIT: usize = 256 * 1024;
let dir = test_dir("limits");
let s = new_store(&dir);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let at = vec![b'a'; LIMIT];
let out = m.ok(
"books.attach_receipt",
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&at), "sha256": sha256(&at)}),
);
let at = vec![b'a'; OLD_LIMIT + 1];
let up = m.upload(&at, "big.bin", "application/octet-stream", "receipt");
let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
let at_id = out["attachment_id"].as_u64().unwrap();
let doc = vec![b'b'; LIMIT];
let doc = vec![b'b'; OLD_LIMIT + 1];
let up = m.upload(&doc, "big.bin", "application/octet-stream", "document");
let out = m.ok(
"books.file_document",
json!({"category": "other", "file_name": "big.bin",
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
"sha256": sha256(&doc)}),
json!({"category": "other", "upload_id": up}),
);
let doc_id = out["attachment_id"].as_u64().unwrap();
let listed = m.ok("books.list_receipts", json!({}));
@@ -2918,32 +2906,31 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
.find(|r| r["attachment_id"] == json!(id))
.unwrap()
.clone();
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole");
// And it comes back inline, whole.
assert_eq!(row["size_bytes"], json!(OLD_LIMIT + 1), "stored whole");
// And it comes back by its download, whole.
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(&b64d(got["content_base64"].as_str().unwrap()), bytes);
assert_eq!(&m.handed(&got["file"]), bytes);
}
let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let over = vec![b'c'; LIMIT + 1];
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": "over.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&over), "sha256": sha256(&over)}),
json!({"file_name": "s.bin", "mime_type": "application/octet-stream",
"content_base64": "YQ==", "sha256": sha256(b"a")}),
),
(
"books.file_document",
json!({"category": "other", "file_name": "over.bin",
"mime_type": "application/octet-stream", "content_base64": b64e(&over),
"sha256": sha256(&over)}),
json!({"category": "other", "file_name": "s.bin",
"mime_type": "application/octet-stream", "content_base64": "YQ==",
"sha256": sha256(b"a")}),
),
] {
let why = m.refuse(tool, args);
assert!(
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()),
why.contains("books.begin_upload") && why.contains("unknown argument"),
"{tool}: {why}"
);
}
@@ -2954,16 +2941,16 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
);
}
/// The bytes a filing tool takes are inline base64 with its own SHA-256,
/// or an upload; never both, never neither. A sha256 missing, or not the
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused
/// (guard base64_corrupt); inline bytes need their file_name and
/// mime_type. An upload_id that names no upload is refused, and
/// books.begin_upload names the attachment for bytes stored already, and
/// begins an upload for new ones (journaling nothing). Every refusal
/// A filing tool takes its file only as an upload (D-46): with none it is
/// refused, naming upload_id; an argument that once carried the bytes
/// inline (content_base64, sha256, csv_text), alone or beside an upload,
/// is refused as unknown, pointing at books.begin_upload; an upload_id
/// that names no upload is refused. books.begin_upload names the
/// attachment for bytes stored already (its sha256 in any case), and
/// begins an upload for new ones, journaling nothing. Every refusal
/// stores nothing.
#[test]
fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
fn a_filing_tool_takes_its_file_only_as_an_upload() {
let dir = test_dir("inline");
let s = new_store(&dir);
drop(s);
@@ -2972,43 +2959,36 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let other: &[u8] = b"%PDF-1.4 a receipt!";
let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let before = journal();
let up = m.upload(bytes, "r.pdf", "application/pdf", "receipt");
for (tool, base) in [
("books.attach_receipt", json!({})),
("books.file_document", json!({"category": "other"})),
] {
let with = |extra: Value| {
let mut args = base.clone();
for (k, v) in json!({"file_name": "r.pdf", "mime_type": "application/pdf"})
.as_object()
.unwrap()
.iter()
.chain(extra.as_object().unwrap())
{
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
args
};
for (args, said) in [
// Neither, both.
(with(json!({})), "books.begin_upload"),
(with(json!({})), "upload_id"),
(
with(json!({"upload_id": "u1", "content_base64": b64e(bytes),
"sha256": sha256(bytes)})),
"not both",
),
// The sha256 missing, another file's, not hex; base64 that isn't.
(with(json!({"content_base64": b64e(bytes)})), "sha256"),
(
with(json!({"content_base64": b64e(bytes), "sha256": sha256(other)})),
"base64_corrupt",
with(json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha256(bytes)})),
"books.begin_upload",
),
(
with(json!({"content_base64": b64e(bytes), "sha256": "not hex"})),
"base64_corrupt",
with(json!({"content_base64": "JVBERi0xLjQ="})),
"unknown argument",
),
(with(json!({"sha256": sha256(bytes)})), "unknown argument"),
(
with(json!({"upload_id": up, "content_base64": "JVBERi0xLjQ="})),
"unknown argument",
),
(
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})),
"base64_corrupt",
with(json!({"upload_id": up, "sha256": sha256(bytes)})),
"unknown argument",
),
// An upload there is none of.
(
@@ -3019,22 +2999,10 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let why = m.refuse(tool, args.clone());
assert!(why.contains(said), "{tool} {args}: {why}");
}
// Inline bytes need their name and type.
for missing in ["file_name", "mime_type"] {
let mut args = with(json!({"content_base64": b64e(bytes), "sha256": sha256(bytes)}));
args.as_object_mut().unwrap().remove(missing);
let why = m.refuse(tool, args);
assert!(why.contains(missing), "{tool} without {missing}: {why}");
}
}
// A sha256 given in capitals, or with spaces around it, is the same
// hash.
let out = m.ok(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64e(bytes),
"sha256": format!(" {} ", sha256(bytes).to_ascii_uppercase())}),
);
assert_eq!(journal(), before, "a refusal stored something");
// Every refusal left the upload: filed now.
let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
assert_eq!(out["sha256"], sha256(bytes));
let id = out["attachment_id"].as_u64().unwrap();
assert_ne!(journal(), before);
@@ -3054,8 +3022,8 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let begun = m.ok("books.begin_upload", up(sha256(other)));
assert!(begun["upload_id"].is_string(), "{begun}");
assert_eq!(journal(), stored, "beginning an upload journals nothing");
// The CSV imports take csv_text or an upload, never both, never
// neither; an upload there is none of is refused.
// The CSV imports take only an upload; the text inline is refused as
// unknown, alone or beside one; an upload there is none of is refused.
for (tool, base) in [
(
"books.import_bank_csv",
@@ -3064,8 +3032,12 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
("books.import_chart_csv", json!({})),
] {
for (extra, said) in [
(json!({}), "csv_text"),
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"),
(json!({}), "upload_id"),
(json!({"csv_text": "a,b\n"}), "books.begin_upload"),
(
json!({"csv_text": "a,b\n", "upload_id": "u1"}),
"unknown argument",
),
(json!({"upload_id": "u1"}), "there is no upload \"u1\""),
] {
let mut args = base.clone();
@@ -3079,17 +3051,17 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
assert_eq!(journal(), stored, "a refusal stored something");
}
/// books.get_receipt with view "bytes" gives a file's bytes inline up to
/// 256 KiB; a larger one, its download (a stored file, fetched with
/// books:read by `GET /b/{book}/a/{id}`: those very bytes), never its
/// bytes inline.
/// books.get_receipt with view "bytes" hands every file over, whatever its
/// size (D-46): its download, made for the caller's token, is those very
/// bytes, as is the stored file's own route (`GET /b/{book}/a/{id}`, for
/// books:read); the answer never carries the bytes.
#[test]
fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
const LIMIT: usize = 256 * 1024;
fn get_receipt_hands_every_file_over_by_its_download() {
const OLD_LIMIT: usize = 256 * 1024;
let dir = test_dir("get-bytes");
let mut s = new_store(&dir);
let at = vec![b'x'; LIMIT];
let over = vec![b'y'; LIMIT + 1];
let at = vec![b'x'; OLD_LIMIT];
let over = vec![b'y'; OLD_LIMIT + 1];
let (at_id, _, _) = s
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
.unwrap();
@@ -3098,27 +3070,29 @@ fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
.unwrap();
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": at_id, "view": "bytes"}),
);
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at);
let big = m.ok(
"books.get_receipt",
json!({"attachment_id": over_id, "view": "bytes"}),
);
assert!(big.get("content_base64").is_none(), "{big}");
let download = big["download"].as_str().unwrap_or_else(|| panic!("{big}"));
assert!(download.contains(&format!(" {over_id} ")), "{download}");
assert_eq!(big["size_bytes"], json!(LIMIT + 1));
assert_eq!(big["sha256"], sha256(&over));
let (status, bytes) = m.wire.get(&format!("a/{over_id}"));
assert_eq!(status, 200);
assert_eq!(bytes, over);
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id}));
assert_eq!(meta["size_bytes"], json!(LIMIT + 1));
assert_eq!(meta["sha256"], sha256(&over));
assert!(meta.get("content_base64").is_none(), "{meta}");
for (id, bytes, name) in [(at_id, &at, "at.bin"), (over_id, &over, "over.bin")] {
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert!(got.get("content_base64").is_none(), "{got}");
assert!(got.get("download").is_none(), "{got}");
let file = &got["file"];
assert_eq!(file["file_name"], name);
assert_eq!(file["mime_type"], "application/octet-stream");
assert_eq!(file["size_bytes"], json!(bytes.len()));
assert_eq!(file["sha256"], sha256(bytes));
let download = file["download"].as_str().unwrap_or_else(|| panic!("{got}"));
assert!(download.contains(" ex_"), "{download}");
assert_eq!(&m.handed(file), bytes);
let (status, stored) = m.wire.get(&format!("a/{id}"));
assert_eq!(status, 200);
assert_eq!(&stored, bytes);
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
assert_eq!(meta["size_bytes"], json!(bytes.len()));
assert_eq!(meta["sha256"], sha256(bytes));
assert!(meta.get("file").is_none(), "{meta}");
}
}
/// Bank CSV import: money in debits the bank, money out credits it; the
@@ -3131,13 +3105,15 @@ fn bank_import_puts_money_in_and_out_on_the_right_sides() {
let s = new_store(&dir);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let csv = "Date,Description,Amount\n\
03/04/2025,Corner Coffee Shop,-4.50\n\
13/04/2025,Client payment,250.00\n\
20/04/2025,STARBUCKS COFFEE #12,-6.00\n";
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let out = m.ok(
"books.import_bank_csv",
json!({
"csv_text": "Date,Description,Amount\n\
03/04/2025,Corner Coffee Shop,-4.50\n\
13/04/2025,Client payment,250.00\n\
20/04/2025,STARBUCKS COFFEE #12,-6.00\n",
"upload_id": up,
"bank_account_code": "1000",
"default_offset_code": "4000",
"rules": {"coffee": "6010", "starbucks coffee": "6020"},
+2 -2
View File
@@ -202,7 +202,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
"books.export",
json!({"report": "members_equity", "format": "json", "start": START, "end": end}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: Value = serde_json::from_str(&mcp.wire.fetch_text(&me["file"])).unwrap();
for (col, engine) in me["members"]
.as_array()
.unwrap()
@@ -263,7 +263,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
"books.export",
json!({"report": "general_journal", "format": "iif", "as_of": end}),
);
assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}");
assert_eq!(mcp.wire.fetch_text(&iif["file"]), x.iif, "{end}");
}
// Party totals for the year, on both bases.
for (name, a) in &parties {
+18 -57
View File
@@ -282,12 +282,15 @@ impl Mcp {
}
fn file_bill_of_sale(&mut self) -> u64 {
let upload = self.wire.upload(
b"bill of sale",
"bill-of-sale.txt",
"text/plain",
"document",
);
self.ok(
"books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
"content_base64": b64(b"bill of sale"),
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
"description": "Bill of sale"}),
json!({"category": "legal", "upload_id": upload, "description": "Bill of sale"}),
)["attachment_id"]
.as_u64()
.unwrap()
@@ -313,49 +316,6 @@ fn books(name: &str) -> Mcp {
m
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn unb64(s: &str) -> Vec<u8> {
let val = |c: u8| -> u32 {
match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => panic!("not base64: {c}"),
}
};
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let mut out = Vec::new();
for chunk in clean.chunks(4) {
let pad = chunk.iter().filter(|&&b| b == b'=').count();
let mut n = 0u32;
for (i, &b) in chunk.iter().enumerate() {
n |= if b == b'=' { 0 } else { val(b) } << (18 - 6 * i);
}
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
out.extend_from_slice(&bytes[..3 - pad]);
}
out
}
/// Decimal strings compared by value.
fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') {
@@ -677,9 +637,12 @@ fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"}));
let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]}));
assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order");
let pdf = unb64(r["content_base64"].as_str().unwrap());
// Handed over (D-46): its bytes come by its download, never here.
assert!(r.get("content_base64").is_none(), "{r}");
let pdf = m.wire.fetch(&r["file"]);
assert!(pdf.starts_with(b"%PDF"), "a PDF");
assert!(!r["file_name"].as_str().unwrap().is_empty());
assert_eq!(r["file"]["mime_type"], "application/pdf");
assert!(!r["file"]["file_name"].as_str().unwrap().is_empty());
// By id too.
let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]}));
assert_eq!(r["tags"], json!(["DESK/7"]));
@@ -697,7 +660,7 @@ fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
assert_eq!(tags, want);
// skip: 0 to 29.
let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29}));
assert!(unb64(r["content_base64"].as_str().unwrap()).starts_with(b"%PDF"));
assert!(m.wire.fetch(&r["file"]).starts_with(b"%PDF"));
m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30}));
// Unknown asset.
m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]}));
@@ -795,7 +758,7 @@ fn export_fixed_asset_register_reconciles_to_the_books() {
);
assert_eq!(r["report"], "fixed_asset_register");
assert_eq!(r["format"], format);
r["content"].as_str().unwrap().to_string()
m.wire.fetch_text(&r["file"])
};
// Agreeing: depreciation posted through February.
@@ -839,7 +802,7 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
let text = r["content"].as_str().unwrap();
let text = &m.wire.fetch_text(&r["file"]);
assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}");
}
@@ -1385,13 +1348,11 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let export = |m: &mut Mcp, format: &str| -> String {
m.read_only(
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
)["content"]
.as_str()
.unwrap()
.to_string()
);
m.wire.fetch_text(&r["file"])
};
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
let rows = j["assets"].as_array().unwrap();
+36 -34
View File
@@ -274,16 +274,6 @@ impl Mcp {
}
}
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
@@ -314,13 +304,22 @@ fn books(name: &str) -> Mcp {
}
impl Mcp {
/// 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)
}
/// File a document; its id.
fn doc(&mut self, category: &str, bytes: &[u8], link_to: Value) -> u64 {
let upload = self.upload(
bytes,
&format!("{category}.pdf"),
"application/pdf",
"document",
);
self.ok(
"books.file_document",
json!({"category": category, "file_name": format!("{category}.pdf"),
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
"link_to": link_to}),
json!({"category": category, "upload_id": upload, "link_to": link_to}),
)["attachment_id"]
.as_u64()
.unwrap()
@@ -328,8 +327,8 @@ impl Mcp {
/// Attach a receipt (to `entry`, or unlinked); its id.
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(bytes), "sha256": sha(bytes)});
let upload = self.upload(bytes, "r.pdf", "application/pdf", "receipt");
let mut args = json!({"upload_id": upload});
if let Some(e) = entry {
args["entry_id"] = json!(e);
}
@@ -691,6 +690,8 @@ fn filing_links_in_the_same_call_all_or_nothing() {
.len()
};
let n = count(&mut m);
// One upload, left for the next call by each refused one.
let r = m.upload(b"r", "r.pdf", "application/pdf", "receipt");
for link_to in [
json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]),
json!([{"kind": "company"}, {"kind": "company"}]),
@@ -699,26 +700,23 @@ fn filing_links_in_the_same_call_all_or_nothing() {
] {
m.refuse(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
json!({"entry_id": e, "upload_id": r, "link_to": link_to}),
);
}
let c = m.upload(b"c", "c.pdf", "application/pdf", "document");
m.refuse(
"books.file_document",
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"c"), "sha256": sha(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
json!({"category": "contract", "upload_id": c, "link_to": [{"kind": "asset", "id": "NOPE"}]}),
);
// An unknown entry_id is refused before anything is filed.
m.refuse(
"books.attach_receipt",
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": [{"kind": "company"}]}),
json!({"entry_id": 999, "upload_id": r, "link_to": [{"kind": "company"}]}),
);
assert_eq!(count(&mut m), n, "nothing filed");
let out = m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"), "sha256": sha(b"r"),
json!({"entry_id": e, "upload_id": r,
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
);
assert_eq!(
@@ -729,10 +727,10 @@ fn filing_links_in_the_same_call_all_or_nothing() {
assert_eq!(out["links"][0]["id"], json!(e));
assert_eq!(m.count("receipt.attached"), 1);
assert_eq!(m.count("attachment.linked"), 2);
let cert = m.upload(b"certificate", "cert.pdf", "application/pdf", "document");
let doc = m.ok(
"books.file_document",
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
json!({"category": "formation", "upload_id": cert, "link_to": [{"kind": "company"}]}),
);
assert_eq!(kinds(&doc), ["company"]);
assert_eq!(m.count("document.filed"), 1);
@@ -759,10 +757,10 @@ fn documents_file_under_the_attachment_kinds() {
c
);
}
let x = m.upload(b"x", "x", "text/plain", "document");
m.refuse(
"books.file_document",
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
json!({"category": "receipt-ish", "upload_id": x}),
);
}
@@ -1249,13 +1247,15 @@ fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
"books.withdraw_attachment",
json!({"attachment_id": gone, "reason": "superseded"}),
);
let args = |source: u64| {
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
let args = |upload: &str, source: u64| {
json!({"upload_id": upload, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source})
};
m.refuse("books.import_bank_csv", args(999));
m.refuse("books.import_bank_csv", args(gone));
let out = m.ok("books.import_bank_csv", args(src));
// One upload, left for the next call by each refused one.
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
m.refuse("books.import_bank_csv", args(&up, 999));
m.refuse("books.import_bank_csv", args(&up, gone));
let out = m.ok("books.import_bank_csv", args(&up, src));
assert_eq!(out["count"], 2);
assert_eq!(out["source_attachment_id"], json!(src));
assert_eq!(out["source_linked"], true);
@@ -1271,13 +1271,15 @@ fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
);
}
// Linked already: imported again, not linked twice.
let again = m.ok("books.import_bank_csv", args(src));
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let again = m.ok("books.import_bank_csv", args(&up, src));
assert_eq!(again["source_linked"], false);
assert_eq!(m.count("attachment.linked"), 1);
// Without a source, as before.
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let plain = m.ok(
"books.import_bank_csv",
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999"}),
json!({"upload_id": up, "bank_account_code": "1000", "default_offset_code": "6999"}),
);
assert!(plain["source_attachment_id"].is_null());
assert_eq!(plain["source_linked"], false);
+65 -67
View File
@@ -426,13 +426,14 @@ impl Mcp {
id
}
/// 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 attach(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({
"file_name": "receipt.txt",
"mime_type": "text/plain",
"content_base64": b64(bytes),
"sha256": sha(bytes),
});
let upload = self.upload(bytes, "receipt.txt", "text/plain", "receipt");
let mut args = json!({"upload_id": upload});
if let Some(e) = entry {
args["entry_id"] = json!(e);
}
@@ -544,29 +545,6 @@ fn canon(s: &str) -> String {
}
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
/// A chart used by several tests.
const BASE_CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
@@ -627,10 +605,13 @@ fn happy_path(name: &str) -> Mcp {
("6010", "Platform Fees", "expense"),
("6500", "Depreciation Expense", "expense"),
]);
m.ok(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n"}),
let chart = m.upload(
b"code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n",
"chart.csv",
"text/csv",
"import",
);
m.ok("books.import_chart_csv", json!({"upload_id": chart}));
m.ok(
"books.renumber_account",
json!({"account": "7000", "code": "7100"}),
@@ -717,11 +698,11 @@ fn happy_path(name: &str) -> Mcp {
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
let w9 = m.upload(w9_pdf, "w9.pdf", "application/pdf", "w9");
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein",
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
"tin": "79-9714321", "tin_type": "ein", "w9_upload_id": w9}),
);
m.ok("books.get_w9", json!({"party": "Landlord"}));
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
@@ -800,19 +781,22 @@ fn happy_path(name: &str) -> Mcp {
"books.get_receipt",
json!({"attachment_id": r_old, "view": "bytes"}),
);
let oa = m.upload(
b"operating agreement",
"agreement.txt",
"text/plain",
"document",
);
let doc = m.ok(
"books.file_document",
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
"content_base64": b64(b"operating agreement"),
"sha256": sha(b"operating agreement"), "description": "OA"}),
json!({"category": "legal", "upload_id": oa, "description": "OA"}),
)["attachment_id"]
.as_u64()
.unwrap();
let lease = m.upload(b"lease", "lease.txt", "text/plain", "document");
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"),
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
json!({"category": "contract", "upload_id": lease, "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -833,7 +817,7 @@ fn happy_path(name: &str) -> Mcp {
let local = m.attach(None, b"a local receipt");
let up = m.ok(
"books.begin_upload",
json!({"sha256": sha(b"a local receipt"), "size": 15, "mime_type": "text/plain",
json!({"sha256": ledger_store::journal::sha256_hex(b"a local receipt"), "size": 15, "mime_type": "text/plain",
"file_name": "local-receipt.txt", "purpose": "receipt"}),
);
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
@@ -983,17 +967,17 @@ fn happy_path(name: &str) -> Mcp {
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "document");
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes()),
"sha256": sha(csv.as_bytes())}),
json!({"category": "import_source", "upload_id": up}),
)["attachment_id"]
.as_u64()
.unwrap();
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
m.ok(
"books.import_bank_csv",
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
json!({"upload_id": up, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
@@ -1005,11 +989,15 @@ fn happy_path(name: &str) -> Mcp {
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let up = m.upload(
b"%PDF statement",
"2026-01.pdf",
"application/pdf",
"document",
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement"),
"sha256": sha(b"%PDF statement")}),
json!({"category": "statement", "upload_id": up}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -1324,9 +1312,10 @@ enum Op {
date: String,
confirmed: Option<bool>,
},
/// A receipt: these bytes, or (None) bytes given inline.
Attach {
entry: Option<Pick>,
content: String,
content: Option<Vec<u8>>,
},
Link {
att: Pick,
@@ -1424,8 +1413,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
2 => (pick(), date_strategy(), confirmed())
.prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }),
3 => (prop::option::of(pick()), prop_oneof![
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(|b| b64(&b)),
1 => Just("!!not base64!!".to_string()),
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(Some),
1 => Just(None),
])
.prop_map(|(entry, content)| Op::Attach { entry, content }),
1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }),
@@ -1487,11 +1476,10 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
}
if *receipt {
let bytes = format!("receipt for entry {e}");
let up = m.upload(bytes.as_bytes(), "r.txt", "text/plain", "receipt");
let out = m.tool(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain",
"content_base64": b64(bytes.as_bytes()),
"sha256": sha(bytes.as_bytes())}),
json!({"entry_id": e, "upload_id": up}),
);
k.attachments.extend(new_id(&out, "attachment_id"));
}
@@ -1521,19 +1509,30 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
"confirmed": opt(*confirmed)}),
),
Op::Attach { entry, content } => {
// The bytes' own SHA-256 (content that is no base64 has none).
let sha256 = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(content)
.map_or_else(|_| "0".repeat(64), |b| sha(&b))
let entry = entry.map(|p| p.resolve(&k.entries));
// New bytes go by their upload; bytes stored already (begin_upload
// names their attachment) are attached by an upload there is
// none of; none at all, by the inline argument D-46 removed.
let given = match content {
Some(bytes) => {
let begun = m.wire.begin_and_send(
bytes,
"r.bin",
"application/octet-stream",
"receipt",
);
let up = begun["upload_id"]
.as_str()
.map_or_else(|| format!("up_{}", "a".repeat(32)), str::to_string);
json!({"upload_id": up})
}
None => json!({"content_base64": "cmVjZWlwdA=="}),
};
(
"books.attach_receipt",
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
"mime_type": "application/octet-stream", "content_base64": content,
"sha256": sha256}),
)
let mut args = json!({"entry_id": entry});
for (key, v) in given.as_object().unwrap() {
args[key] = v.clone();
}
("books.attach_receipt", args)
}
Op::Link { att, entry } => (
"books.link_receipt",
@@ -2298,7 +2297,6 @@ fn helpers_self_check() {
assert_eq!(canon("007.50"), "7.5");
assert_eq!(canon("5.00"), "5");
assert_eq!(canon("-0.00"), "0");
assert_eq!(b64(b"abcd"), "YWJjZA==");
let mut bad = Vec::new();
money_walk(
&json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}),
+1 -1
View File
@@ -480,7 +480,7 @@ fn defect14_party_totals_take_a_basis() {
"books.export",
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
let me: Value = serde_json::from_str(&m.wire.fetch_text(&me["file"])).unwrap();
for who in ["Tobin Harlow", "Morgan Ellis"] {
let col = me["members"]
.as_array()
+5 -12
View File
@@ -271,15 +271,6 @@ fn books_in(name: &str, p2: bool) -> Mcp {
m
}
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
impl Mcp {
/// Draft with lines (account, side, amount, class), post it; the id.
fn tagged(
@@ -308,12 +299,14 @@ impl Mcp {
id
}
/// File `bytes` as a contract, uploaded as an agent uploads them.
fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out {
let upload = self
.wire
.upload(bytes, "spec.pdf", "application/pdf", "document");
self.tool(
"books.file_document",
json!({"category": "contract", "file_name": "spec.pdf",
"mime_type": "application/pdf", "content_base64": b64(bytes),
"sha256": sha(bytes), "link_to": link_to}),
json!({"category": "contract", "upload_id": upload, "link_to": link_to}),
)
}
}
+14 -14
View File
@@ -60,6 +60,12 @@ impl Mcp {
}
/// Call a tool; panic on a refusal. Returns structuredContent.
/// Upload a CSV for an import as an agent does (D-46): the upload_id.
fn csv_upload(&mut self, csv: &str) -> String {
self.wire
.upload(csv.as_bytes(), "chart.csv", "text/csv", "import")
}
fn call(&mut self, tool: &str, args: Value) -> Value {
let result = self.result(tool, args);
assert_ne!(
@@ -243,10 +249,8 @@ fn require_tax_category_refuses_defect_8() {
);
// The import: refused whole, saying why.
let before = m.events();
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
);
let up = m.csv_upload("code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n");
let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
assert!(
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
"{err}"
@@ -296,10 +300,8 @@ fn require_tax_category_refuses_defect_8() {
json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
let out = m.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
);
let up = m.csv_upload("code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n");
let out = m.call("books.import_chart_csv", json!({"upload_id": up}));
assert_eq!(out["count"], 3);
assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}");
}
@@ -340,10 +342,8 @@ fn a_new_account_named_like_a_sibling_is_refused() {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
);
let up = m.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n");
let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
assert_eq!(m.events(), before);
// Top level: no sibling of the child.
@@ -389,7 +389,7 @@ fn a_gain_on_business_property_is_exported_apart() {
}
let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"});
let out = m.call("books.export", range("json"));
let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let j: Value = serde_json::from_str(&m.wire.fetch_text(&out["file"])).unwrap();
assert_eq!(
j["form_4797"],
json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)",
@@ -400,7 +400,7 @@ fn a_gain_on_business_property_is_exported_apart() {
assert_eq!(j["book_net"], "10.00");
assert_eq!(j["tax_net"], "10.00");
let out = m.call("books.export", range("csv"));
let csv = out["content"].as_str().unwrap();
let csv = &m.wire.fetch_text(&out["file"]);
assert!(
csv.lines().any(|l| l
== "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"),
+54 -61
View File
@@ -60,17 +60,6 @@ struct Mcp {
books_dir: Option<TestDir>,
}
/// `bytes` as a tool takes them inline: base64.
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
@@ -466,6 +455,24 @@ impl Mcp {
}
self.tool("books.set_w9", args)
}
/// 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)
}
/// Upload a W-9 form, named and typed for what its first bytes say
/// (w9.pdf, w9.png, w9.jpg; else w9.bin): the upload_id.
fn w9_upload(&mut self, bytes: &[u8]) -> String {
let (name, mime) = if bytes.starts_with(b"%PDF-") {
("w9.pdf", "application/pdf")
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
("w9.png", "image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
("w9.jpg", "image/jpeg")
} else {
("w9.bin", "application/octet-stream")
};
self.upload(bytes, name, mime, "w9")
}
/// A W-9 file (a PDF); it shows the TIN, like the real one.
fn w9_file(&self, tin: &str) -> Vec<u8> {
format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes()
@@ -608,7 +615,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
m.add_party("IRS", &["tax_agency"]);
let pdf = m.w9_file(SSN);
let txt = b"W-9";
let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
for (party, extra) in [
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
(
@@ -652,41 +658,28 @@ fn set_w9_refusals_write_nothing_anywhere() {
out.err_text()
);
}
// The W-9 file, each refused for its own reason.
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
// The W-9 file, each refused for its own reason. Every upload is
// its own: a refused call leaves it unused.
let up_txt = m.upload(txt, "w9.txt", "text/plain", "w9");
let up_pdf = m.upload(&pdf, "w9.pdf", "application/pdf", "w9");
let up_receipt = m.upload(&pdf, "w9.pdf", "application/pdf", "receipt");
for (extra, why) in [
(
json!({"w9_content_base64": b64(txt), "w9_sha256": sha(txt)}),
"a PDF, PNG, or JPEG",
),
(
json!({"w9_content_base64": b64(&pdf)}),
"comes with its sha256",
),
(
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(txt)}),
"lost on the way",
),
(
json!({"w9_content_base64": "!!not base64", "w9_sha256": sha(&pdf)}),
"not valid base64",
),
(
json!({"w9_content_base64": b64(&big), "w9_sha256": sha(&big)}),
"upload it with books.begin_upload",
),
(
json!({"w9_content_base64": long, "w9_sha256": sha(&big)}),
"inline is at most",
),
(json!({"w9_upload_id": up_txt}), "a PDF, PNG, or JPEG"),
(json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""),
(json!({"w9_upload_id": up_receipt}), "begun for receipt"),
(
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}),
"not both",
json!({"w9_upload_id": up_pdf, "w9_file_name": "../w9.pdf"}),
"no folder",
),
// The inline form D-46 removed: an unknown argument, pointing to
// the upload.
(
json!({"w9_content_base64": "JVBERi0xLjQ=", "w9_sha256": "00"}),
"books.begin_upload",
),
(
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf), "w9_file_name": "../w9.pdf"}),
"no folder",
json!({"w9_upload_id": up_pdf, "w9_sha256": "00"}),
"unknown argument",
),
] {
let mut args = json!({"tin": SSN, "tin_type": "ssn"});
@@ -757,9 +750,10 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
let mut m = books("w9-file");
let acme = m.party_id("Acme Design");
let src = m.w9_file(SSN);
let up1 = m.w9_upload(&src);
let Out::Ok(p) = m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up1}),
) else {
panic!("set_w9 with a file")
};
@@ -790,10 +784,8 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
// A W-9 file alone (the TIN typed in later) records the vault too.
m.add_party("Solo", &["contractor"]);
let solo = m.w9_file("000");
let Out::Ok(p) = m.w9(
"Solo",
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
) else {
let up2 = m.w9_upload(&solo);
let Out::Ok(p) = m.w9("Solo", json!({"tin_last4": "0000", "w9_upload_id": up2})) else {
panic!("a W-9 file alone")
};
assert_eq!(p["w9"]["in_vault"], true);
@@ -816,10 +808,8 @@ fn every_w9_file_type_is_taken() {
{
let party = format!("Payee {n}");
m.add_party(&party, &["contractor"]);
let Out::Ok(p) = m.w9(
&party,
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
) else {
let up3 = m.w9_upload(bytes);
let Out::Ok(p) = m.w9(&party, json!({"tin_last4": "0000", "w9_upload_id": up3})) else {
panic!("{name} refused")
};
assert_eq!(p["w9"]["in_vault"], true, "{name}");
@@ -830,9 +820,10 @@ fn every_w9_file_type_is_taken() {
// Its own name, when given; given alone, a name is refused.
m.add_party("Payee N", &["contractor"]);
let bytes = b"%PDF-1.4 Form W-9 named";
let up4 = m.w9_upload(bytes);
let Out::Ok(_) = m.w9(
"Payee N",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes),
json!({"tin_last4": "0000", "w9_upload_id": up4,
"w9_file_name": "payee-n-w9.pdf"}),
) else {
panic!("named W-9 refused")
@@ -855,11 +846,9 @@ fn every_w9_file_type_is_taken() {
b"\xff\xd8 short".as_slice(),
b"GIF89a".as_slice(),
] {
let up5 = m.w9_upload(bytes);
let why = m
.w9(
"Payee X",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
)
.w9("Payee X", json!({"tin_last4": "0000", "w9_upload_id": up5}))
.err_text();
assert!(why.contains("PDF, PNG, or JPEG"), "{why}");
}
@@ -870,9 +859,10 @@ fn every_w9_file_type_is_taken() {
fn a_locked_vault_says_so_and_never_guesses() {
let mut m = books("locked");
let src = m.w9_file(SSN);
let up6 = m.w9_upload(&src);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up6}),
);
m.pay("Acme Design", "700.00");
let key = std::fs::read(m.key()).unwrap();
@@ -1006,9 +996,10 @@ fn purge_deletes_the_vault_records_and_journals_why() {
let mut m = books("purge");
let acme = m.party_id("Acme Design");
let src = m.w9_file(SSN);
let up7 = m.w9_upload(&src);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up7}),
);
// Clearing while the vault holds it: refused.
let why = m
@@ -1089,9 +1080,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
);
let src = m.w9_file(SSN);
let up8 = m.w9_upload(&src);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up8}),
);
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
@@ -1245,8 +1237,9 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
}
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
// The handed-over files were scanned: two packages (without TINs,
// with them), the W-9 file, the worksheet CSV.
assert_eq!(scanned.len(), 4, "{scanned:?}");
// with them), the W-9 file, the worksheet CSV, and the journal's two
// exports (D-46: handed over too).
assert_eq!(scanned.len(), 6, "{scanned:?}");
assert!(m.transcript.len() > 40, "the session used the books");
// The server's log.
let log = m.server_log();
+121 -51
View File
@@ -10,8 +10,8 @@
//! INVARIANTS (oracle):
//! 1. Every file the server read or wrote with full TINs is refused by
//! every way a file enters the books (books.attach_receipt and
//! books.file_document, its bytes given inline), from any session and
//! after the server restarts.
//! books.file_document, its bytes uploaded as an agent uploads them:
//! D-46), from any session and after the server restarts.
//! 2. Text holding a full TIN is refused, whoever made it: one the vault
//! holds, in every common rendering (nine digits; 3-2-4 joined by
//! hyphens, spaces or dots; 2-7 joined by a hyphen or a space), and,
@@ -55,6 +55,10 @@ struct Books {
dir: PathBuf,
inbox: PathBuf,
out: PathBuf,
/// Each upload a refusal left unused, by its bytes' SHA-256: named
/// again by the next call (files.rs INVARIANT 1), so the refusals
/// never pile up past a token's MAX_LIVE.
left: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
fn books(tag: &str) -> Books {
@@ -83,6 +87,38 @@ fn books(tag: &str) -> Books {
served,
inbox,
out,
left: Default::default(),
}
}
impl Books {
/// An upload of `bytes` named `name` on `c` (the books' token's
/// session), as an agent uploads them: one a refusal left unused if
/// there is one, else a new one (purpose `purpose`). Its id.
async fn upload(&self, c: &Client, bytes: &[u8], name: &str, purpose: &str) -> String {
let sha = ledger_store::journal::sha256_hex(bytes);
let key = format!("{purpose} {sha}");
if let Some(id) = self.left.lock().unwrap().get(&key) {
return id.clone();
}
let token = self.served.home.token(common::BOOK);
let id = common::upload_on(
c,
&self.served.home.run(),
common::BOOK,
&token,
bytes,
(name, "application/octet-stream", purpose),
)
.await;
self.left.lock().unwrap().insert(key, id.clone());
id
}
/// The upload of `bytes` for `purpose` was filed: used up.
fn spent(&self, bytes: &[u8], purpose: &str) {
let key = format!("{purpose} {}", ledger_store::journal::sha256_hex(bytes));
self.left.lock().unwrap().remove(&key);
}
}
@@ -97,15 +133,6 @@ async fn ok(c: &Client, tool: &str, args: Value) -> Value {
.unwrap_or_else(|e| panic!("{tool}: {e}"))
}
/// `bytes` as a tool takes them inline: base64, and their SHA-256 (hex).
fn inline(bytes: &[u8]) -> (String, String) {
use base64::Engine;
(
base64::engine::general_purpose::STANDARD.encode(bytes),
ledger_store::journal::sha256_hex(bytes),
)
}
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by its
/// download, matching the size and SHA-256 it states.
async fn handed(b: &Books, file: &Value) -> Vec<u8> {
@@ -123,20 +150,19 @@ async fn refused_everywhere(
why: &str,
) -> Vec<String> {
let bytes = std::fs::read(path).unwrap();
let (content, sha) = inline(&bytes);
// One upload, refused by each tool and so left for the next.
let upload = b.upload(c, &bytes, name, "receipt").await;
let journal = b.dir.join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap();
let mut refusals = Vec::new();
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "application/octet-stream",
"content_base64": content, "sha256": sha}),
json!({"upload_id": upload, "mime_type": "application/octet-stream"}),
),
(
"books.file_document",
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
json!({"category": "tax", "upload_id": upload, "mime_type": "application/pdf"}),
),
] {
let err = call(c, tool, args).await.expect_err(tool);
@@ -155,7 +181,7 @@ async fn refused_everywhere(
/// books.get_w9 copy of it, a 1099 worksheet and a CPA package with TINs,
/// and the K-1 worksheet extracted from that package are each refused,
/// from another session and after a restart; INVARIANT 2: a CSV holding a
/// TIN that the server never saw is refused, sent as base64.
/// TIN that the server never saw is refused, sent by upload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() {
let b = books("sessions");
@@ -166,8 +192,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
)
.unwrap();
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
let one = connect(&b).await;
let w9_upload = b
.upload(&one, &std::fs::read(&w9).unwrap(), "w9.pdf", "w9")
.await;
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
@@ -196,9 +224,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
&one,
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}),
"tin": SSN, "tin_type": "ssn", "w9_upload_id": w9_upload}),
)
.await;
b.spent(&std::fs::read(&w9).unwrap(), "w9");
ok(
&one,
"books.set_tin",
@@ -207,7 +236,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
.await;
// A bank statement filed in the books: the package carries it, and it
// is no TIN file (its routing numbers are not TINs).
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
accepted_everywhere(&one, &b, "statement.csv", BANK_CSV).await;
// What the server hands over with TINs, from the first session, saved
// outside the books as the human would save it.
let copy = b.out.join("w9-copy.pdf");
@@ -259,12 +288,18 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
// An ordinary file still goes in, every way.
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
let fine = b
.upload(
&two,
b"%PDF-1.4\n\xff lunch receipt, order 12-34\n",
"lunch.pdf",
"receipt",
)
.await;
ok(
&two,
"books.attach_receipt",
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
"content_base64": fine, "sha256": fine_sha}),
json!({"upload_id": fine, "mime_type": "application/pdf"}),
)
.await;
// The books' own files the package carried are not marked: the
@@ -279,7 +314,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
"the package carries the statement"
);
}
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
drop((one, two));
b.served.restart();
@@ -329,49 +364,60 @@ async fn a_refused_set_w9_never_marks_its_file() {
)
.await;
}
let w9_args = |party: &str, path: &Path| {
let (content, sha) = inline(&std::fs::read(path).unwrap());
let w9_args = |party: &str, upload: &str| {
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha})
"tin": SSN, "tin_type": "ssn", "w9_upload_id": upload})
};
for (party, path, why) in [
("Nobody", &w9, "unknown party"),
("Mo", &w9, "1099"),
("Acme", &notes, "PDF, PNG, or JPEG"),
] {
let err = call(&c, "books.set_w9", w9_args(party, path))
let bytes = std::fs::read(path).unwrap();
let upload = b.upload(&c, &bytes, "w9.pdf", "w9").await;
let err = call(&c, "books.set_w9", w9_args(party, &upload))
.await
.expect_err("refused");
assert!(err.contains(why), "{party}: {err}");
}
// Neither file was marked: each is filed as any document is.
let mut filed = Vec::new();
for (name, bytes) in [
("w9-scan.pdf", w9_bytes.clone()),
("notes.txt", std::fs::read(&notes).unwrap()),
] {
let (content, sha) = inline(&bytes);
let upload = b.upload(&c, &bytes, name, "document").await;
let out = ok(
&c,
"books.file_document",
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
)
.await;
b.spent(&bytes, "document");
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
filed.push(out["attachment_id"].clone());
}
// A set_w9 that succeeds marks it.
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
let (content, sha) = inline(&w9_bytes);
let upload = b.upload(&c, &w9_bytes, "w9.pdf", "w9").await;
ok(&c, "books.set_w9", w9_args("Acme", &upload)).await;
b.spent(&w9_bytes, "w9");
// The scan filed before is withdrawn, so its bytes are no attachment
// begin_upload names: they are uploaded, and refused.
ok(
&c,
"books.withdraw_attachment",
json!({"attachment_id": filed[0], "reason": "a W-9: it belongs in the vault"}),
)
.await;
let upload = b.upload(&c, &w9_bytes, "again.pdf", "document").await;
for (tool, args) in [
(
"books.file_document",
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
),
(
"books.attach_receipt",
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
json!({"upload_id": upload, "mime_type": "application/pdf"}),
),
] {
let err = call(&c, tool, args).await.expect_err("marked");
@@ -380,20 +426,42 @@ async fn a_refused_set_w9_never_marks_its_file() {
}
/// Every way a file enters the books takes the text `name` holding
/// `text`, and the journal shows it.
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
let (content, sha) = inline(text.as_bytes());
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
),
/// `text`, and the journal shows it. The same bytes filed already are
/// withdrawn first (books.begin_upload would name them instead of
/// taking them again), so each tool takes them itself.
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
let token = b.served.home.token(common::BOOK);
for (tool, mut args) in [
("books.attach_receipt", json!({"mime_type": "text/csv"})),
(
"books.file_document",
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
"content_base64": content, "sha256": sha}),
json!({"category": "import_source", "mime_type": "text/csv"}),
),
] {
let begun = ok(
c,
"books.begin_upload",
common::upload_args(text.as_bytes(), name, "text/csv", "document"),
)
.await;
if let Some(stored) = begun["existing_attachment_id"].as_u64() {
ok(
c,
"books.withdraw_attachment",
json!({"attachment_id": stored, "reason": "filed again another way"}),
)
.await;
}
let upload = common::upload_on(
c,
&b.served.home.run(),
common::BOOK,
&token,
text.as_bytes(),
(name, "text/csv", "document"),
)
.await;
args["upload_id"] = json!(upload);
let out = ok(c, tool, args).await;
assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
}
@@ -453,7 +521,7 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await;
}
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
drop(c);
// The vault locked (its key gone): the printed shapes are still
@@ -469,11 +537,12 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
accepted_everywhere(
&c,
&b,
"bank-2.csv",
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
)
.await;
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await;
accepted_everywhere(&c, &b, "members-0.csv", "member,tin\nKat,830835531\n").await;
}
/// INVARIANT 3b through the tools: the company's own EIN, as its
@@ -494,6 +563,7 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
.unwrap();
accepted_everywhere(
&c,
&b,
"plan.md",
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
)
@@ -617,8 +687,8 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
};
let open = probes(&c).await;
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await;
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
accepted_everywhere(&c, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
drop(c);
// The vault locked: the same refusals, word for word.