P2 slice 5: files in and out
Files reach the books through the server: uploads by hash and size, then filed; exports (the CPA package, the 1099 worksheet, W-9s) handed over as downloads from the server, never read off disk by the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
d1f3ad891a
commit
b26c099a68
44 files changed
+5075
-2739
No files matched your search
@@ -67,8 +67,9 @@ impl Drop for Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The places one run uses: the books, the vault key, the import root, the
|
||||
/// folder the server runs in (where a relative path lands).
|
||||
/// The places one run uses: the books, the vault key, a folder of files on
|
||||
/// their way in (as a downloads folder), the folder the server runs in
|
||||
/// (where a relative path lands).
|
||||
struct Places {
|
||||
base: PathBuf,
|
||||
books: PathBuf,
|
||||
@@ -130,8 +131,6 @@ impl Mcp {
|
||||
.arg(p.books.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(p.books.join("wanderbooks.toml"))
|
||||
.arg("--import-root")
|
||||
.arg(&p.inbox)
|
||||
.current_dir(&p.sandbox)
|
||||
.env("HOME", p.base.join("home"))
|
||||
.env("XDG_CONFIG_HOME", p.base.join("config"))
|
||||
@@ -256,7 +255,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0"}),
|
||||
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
|
||||
);
|
||||
for (date, dr, cr, amount) in [
|
||||
("2026-01-02", "1000", "3000", "5000.00"),
|
||||
@@ -279,7 +279,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(pdf.as_bytes())}),
|
||||
"content_base64": b64(pdf.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -321,7 +322,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ="}),
|
||||
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
|
||||
);
|
||||
let _ = p;
|
||||
}
|
||||
@@ -500,8 +502,8 @@ fn hostile_numbers() -> Vec<Value> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Paths a tool may be given: inside the sandbox, the import root, the
|
||||
/// books, relative ones, odd ones. Never anywhere a real file lives.
|
||||
/// Paths a tool may be given: inside the sandbox, the inbox, the books,
|
||||
/// relative ones, odd ones. Never anywhere a real file lives.
|
||||
fn hostile_paths(p: &Places) -> Vec<String> {
|
||||
let s = |p: &Path| p.to_str().unwrap().to_string();
|
||||
vec![
|
||||
@@ -618,17 +620,20 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
||||
.boxed();
|
||||
}
|
||||
if n.contains("base64") {
|
||||
likely.extend(
|
||||
[
|
||||
"JVBERi0xLjQ=",
|
||||
"",
|
||||
"!!!not base64",
|
||||
"AAAA",
|
||||
"NTcxLTkzLTI4NDY=",
|
||||
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
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.
|
||||
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.push("0".repeat(64));
|
||||
likely.push("not hex".into());
|
||||
}
|
||||
let pick = |pred: fn(&str) -> bool| -> Vec<String> {
|
||||
hostile_strings().into_iter().filter(|s| pred(s)).collect()
|
||||
@@ -694,6 +699,16 @@ 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==",
|
||||
];
|
||||
|
||||
/// An object's value: each property present or not (required ones mostly
|
||||
/// present), and now and then a field no schema names.
|
||||
fn object_for(
|
||||
@@ -1144,7 +1159,8 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
&mut m,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgaHVnZQ=="}),
|
||||
"content_base64": "JVBERi0xLjQgaHVnZQ==",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
|
||||
);
|
||||
pinned(
|
||||
&mut m,
|
||||
|
||||
@@ -119,7 +119,8 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes())}),
|
||||
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -126,8 +126,7 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -146,11 +145,6 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in [".inbox", ".elsewhere.txt"] {
|
||||
let p = std::path::PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +179,9 @@ fn mcp_end_to_end_over_stdio() {
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -267,6 +264,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
"file_name": "rent.png",
|
||||
"mime_type": "image/png",
|
||||
"content_base64": png,
|
||||
"sha256": sha(b"fake-png-bytes"),
|
||||
}),
|
||||
);
|
||||
assert_eq!(attached["deduplicated"], false);
|
||||
@@ -461,6 +459,7 @@ fn mcp_assets_end_to_end_over_stdio() {
|
||||
"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"),
|
||||
}),
|
||||
);
|
||||
let aid = bill["attachment_id"].as_u64().unwrap();
|
||||
@@ -628,7 +627,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
let att = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||
"content_base64": base64_like(receipt)}),
|
||||
"content_base64": base64_like(receipt),
|
||||
"sha256": sha(receipt)}),
|
||||
);
|
||||
let posted = mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -655,7 +655,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
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")}),
|
||||
"content_base64": base64_like(b"pdf-bytes"),
|
||||
"sha256": sha(b"pdf-bytes")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -782,46 +783,6 @@ fn mcp_resources_over_stdio() {
|
||||
assert!(resp.get("error").is_some());
|
||||
}
|
||||
|
||||
/// `--import-root` replaces the default import root (the downloads
|
||||
/// folder): a file in the named folder is filed; one beside it is not.
|
||||
#[test]
|
||||
fn import_root_flag_replaces_the_defaults() {
|
||||
let dir = test_dir("import-root");
|
||||
let inbox = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&inbox).unwrap();
|
||||
let inside = inbox.join("receipt.txt");
|
||||
let temp = dir.with_extension("elsewhere.txt");
|
||||
for p in [&inside, &temp] {
|
||||
std::fs::write(p, b"a receipt").unwrap();
|
||||
}
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--import-root"), inbox.as_os_str()],
|
||||
);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
let ok = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": inside.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(ok["file_name"], "receipt.txt");
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": temp.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("no such file in the server's import folders"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a session: initialize, then initialized.
|
||||
fn init(mcp: &mut Mcp) -> Value {
|
||||
let init = mcp.request(
|
||||
@@ -836,173 +797,25 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
init
|
||||
}
|
||||
|
||||
/// Through the real tools, inside a root (local_files.rs INVARIANTS 1-3,
|
||||
/// Protected::of; S1 and S5 of the P0 review, I of round 2): the key
|
||||
/// file in force, moved into the root after the server started (a root
|
||||
/// holding the books or a key is refused at startup: startup.rs), is
|
||||
/// sealed with what its link leads to and their folders, but never the
|
||||
/// root itself; links in the root to the journal or a vault record lead
|
||||
/// outside it, and get the one refusal a missing file gets (INVARIANT 2).
|
||||
/// books.import_local_file and books.set_w9's w9_file_path agree.
|
||||
#[test]
|
||||
fn import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = test_dir("sealed-in-root");
|
||||
let root = dir.join("root");
|
||||
let books = dir.join("books");
|
||||
let data = books.join("books-data");
|
||||
let tray = root.join("drop");
|
||||
let config = dir.join("config");
|
||||
for d in [
|
||||
&data,
|
||||
&tray,
|
||||
&root.join("alias"),
|
||||
&root.join("hidden"),
|
||||
&config.join("wanderbooks"),
|
||||
] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("hidden/real.key"), "ab".repeat(32)).unwrap();
|
||||
std::fs::write(root.join("hidden/notes.pdf"), b"%PDF beside the key").unwrap();
|
||||
std::fs::write(root.join("loose.key"), "ef".repeat(32)).unwrap();
|
||||
symlink(root.join("hidden/real.key"), root.join("alias/vault.key")).unwrap();
|
||||
std::fs::write(config.join("wanderbooks/vault.key"), "cd".repeat(32)).unwrap();
|
||||
let settings = |key: &std::path::Path| {
|
||||
std::fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[company]\nname = \"Sealed Co\"\n[vault]\nkey_file = \"{}\"\n",
|
||||
key.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
// Started with the default key (outside the root), so it starts.
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
std::fs::write(tray.join("receipt.pdf"), b"%PDF a receipt").unwrap();
|
||||
std::fs::write(tray.join("w9.pdf"), b"%PDF\xff W-9 scan").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let mut mcp = Mcp::spawn_env(&data, &root_arg, &[("XDG_CONFIG_HOME", &config)]);
|
||||
init(&mut mcp);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.set_tin",
|
||||
json!({"party": "Acme", "tin": "291-12-2846", "tin_type": "ssn"}),
|
||||
);
|
||||
let journal = data.join(ledger_store::JOURNAL_FILE);
|
||||
let vault_record = books
|
||||
.join("vault")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.find(|p| p.extension().is_some_and(|e| e == "bin"))
|
||||
.unwrap();
|
||||
for (name, to) in [
|
||||
("journal-link.pdf", journal.clone()),
|
||||
("record-link.pdf", vault_record.clone()),
|
||||
("default-key-link.pdf", config.join("wanderbooks/vault.key")),
|
||||
("dangling-link.pdf", config.join("wanderbooks/missing.key")),
|
||||
] {
|
||||
symlink(&to, tray.join(name)).unwrap();
|
||||
}
|
||||
// The key in force moves into the root (a link, alias/vault.key ->
|
||||
// hidden/real.key); the settings reload.
|
||||
settings(&root.join("alias/vault.key"));
|
||||
let refuse = |mcp: &mut Mcp, path: &std::path::Path, why: &str| {
|
||||
let path = path.to_str().unwrap();
|
||||
let err = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": path, "category": "document"}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
assert!(!err.contains(dir.to_str().unwrap()), "names a path: {err}");
|
||||
let err = mcp.call_err(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn", "w9_file_path": path}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
};
|
||||
for p in [
|
||||
root.join("hidden/real.key"),
|
||||
root.join("hidden/notes.pdf"),
|
||||
root.join("alias/vault.key"),
|
||||
] {
|
||||
refuse(&mut mcp, &p, "belongs to the books or the TIN vault");
|
||||
}
|
||||
for p in [
|
||||
journal.clone(),
|
||||
vault_record,
|
||||
config.join("wanderbooks/vault.key"),
|
||||
tray.join("journal-link.pdf"),
|
||||
tray.join("record-link.pdf"),
|
||||
tray.join("default-key-link.pdf"),
|
||||
tray.join("dangling-link.pdf"),
|
||||
tray.join("missing.pdf"),
|
||||
] {
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&p,
|
||||
"no such file in the server's import folders (by default your downloads folder)",
|
||||
);
|
||||
}
|
||||
// A key kept loose in the root itself seals the key, not the root.
|
||||
settings(&root.join("loose.key"));
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&root.join("loose.key"),
|
||||
"belongs to the books or the TIN vault",
|
||||
);
|
||||
// Ordinary files in the root are filed (with the key the vault was
|
||||
// made with back in force).
|
||||
mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("receipt.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
// A link is filed under the name and type of the file it leads to.
|
||||
std::fs::write(tray.join("photo.png"), b"\x89PNG a photo").unwrap();
|
||||
symlink(tray.join("photo.png"), tray.join("named-like.pdf")).unwrap();
|
||||
let filed = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("named-like.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(filed["file_name"], "photo.png");
|
||||
let back = mcp.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": filed["attachment_id"]}),
|
||||
);
|
||||
assert_eq!(back["mime_type"], "image/png", "{back}");
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
mcp.call(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": tray.join("w9.pdf").to_str().unwrap()}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
|
||||
/// include_tins, books.export_cpa_package with TINs) are never written
|
||||
/// into an import root, and a copy of one moved into a root is refused by
|
||||
/// its bytes: a TIN-bearing file can't be filed back in as a receipt and
|
||||
/// inside the books, and wherever they are written (a downloads folder
|
||||
/// too), each is refused by its bytes when given back to be filed: a
|
||||
/// TIN-bearing file can't be filed back in as a receipt or document and
|
||||
/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
#[test]
|
||||
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
let dir = test_dir("tin-files");
|
||||
let root = dir.join("inbox");
|
||||
let out = dir.join("out");
|
||||
let books = dir.join("books");
|
||||
let downloads = dir.join("downloads");
|
||||
let config = dir.join("config");
|
||||
for d in [&root, &out, &config] {
|
||||
for d in [&downloads, &config] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("w9.pdf"), b"%PDF W-9 TIN 291-12-2846").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
|
||||
let mut mcp = Mcp::spawn_env(
|
||||
&dir.join("books").join("books-data"),
|
||||
&root_arg,
|
||||
&books.join("books-data"),
|
||||
&[],
|
||||
&[("XDG_CONFIG_HOME", &config)],
|
||||
);
|
||||
init(&mut mcp);
|
||||
@@ -1018,61 +831,92 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": root.join("w9.pdf").to_str().unwrap()}),
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
);
|
||||
let in_root = |name: &str| root.join(name).to_str().unwrap().to_string();
|
||||
// Inside the books: refused, and nothing written there.
|
||||
let before = std::fs::read_dir(&books).unwrap().count();
|
||||
let in_books = |name: &str| books.join(name).to_str().unwrap().to_string();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": in_root("copy.pdf")}),
|
||||
json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
|
||||
),
|
||||
(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_root("1099.csv")}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_root("cpa.zip")}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": root.to_str().unwrap()}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("never saved in the server's import folders"),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
assert!(why.contains("inside the books"), "{tool}: {why}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&root).unwrap().count(),
|
||||
1,
|
||||
"wrote into the root"
|
||||
std::fs::read_dir(&books).unwrap().count(),
|
||||
before,
|
||||
"wrote into the books"
|
||||
);
|
||||
// Saved elsewhere, then moved into the root: refused by its bytes.
|
||||
let w9 = out.join("copy.pdf");
|
||||
// Written to a downloads folder, each is refused by its bytes, by
|
||||
// every way a file is filed.
|
||||
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
|
||||
mcp.call(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": w9.to_str().unwrap()}),
|
||||
json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}),
|
||||
);
|
||||
let csv = out.join("1099.csv");
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}),
|
||||
);
|
||||
for (from, name) in [(&w9, "receipt.pdf"), (&csv, "statement.csv")] {
|
||||
std::fs::copy(from, root.join(name)).unwrap();
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": in_root(name), "category": "receipt"}),
|
||||
);
|
||||
assert!(why.contains("wrote with full TINs"), "{why}");
|
||||
mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
|
||||
);
|
||||
let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(written.len(), 3, "{written:?}");
|
||||
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for path in &written {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
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.file_document",
|
||||
json!({"category": "statement", "file_name": "statement.csv",
|
||||
"mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("wrote with full TINs"),
|
||||
"{tool} {}: {why}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
// Without TINs, the 1099 worksheet may go anywhere a file may.
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "save_csv_to": in_root("plain.csv")}),
|
||||
json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1161,11 @@ fn handbook_tool_and_resources_over_stdio() {
|
||||
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();
|
||||
@@ -1409,7 +1258,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(desc.as_bytes())}),
|
||||
"content_base64": base64_like(desc.as_bytes()),
|
||||
"sha256": sha(desc.as_bytes())}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -1571,7 +1421,8 @@ fn mcp_period_statements_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"r")}),
|
||||
"content_base64": base64_like(b"r"),
|
||||
"sha256": sha(b"r")}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -2047,6 +1898,7 @@ fn mcp_payout_gross_up_over_stdio() {
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
"sha256": sha(b"%PDF-1.4 statement"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2133,6 +1985,7 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
"file_name": "invoice.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 invoice"),
|
||||
"sha256": sha(b"%PDF-1.4 invoice"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
|
||||
@@ -56,8 +56,7 @@ impl Drop for Mcp {
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folder beside it (`.inbox`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -76,7 +75,6 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
let _ = std::fs::remove_dir_all(format!("{}.inbox", self.0.display()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +107,6 @@ fn books_toml(dir: &Path, threshold: &str) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
/// The server's import root, beside the books (never the books dir): files
|
||||
/// given to books.import_local_file.
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let inbox = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&inbox).unwrap();
|
||||
inbox
|
||||
}
|
||||
|
||||
fn data_dir(dir: &Path) -> PathBuf {
|
||||
dir.join("books-data")
|
||||
}
|
||||
@@ -131,8 +121,6 @@ impl Mcp {
|
||||
.arg(data_dir(dir))
|
||||
.arg("--books-toml")
|
||||
.arg(&toml)
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -1737,7 +1725,7 @@ fn post_contribution(m: &mut Mcp) -> u64 {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64e(b"contribution")}),
|
||||
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -2118,11 +2106,24 @@ fn resources_read_back_as_the_matching_tools() {
|
||||
let want = m.ok(tool, args);
|
||||
assert_eq!(got, want, "{uri} reads as {tool}");
|
||||
}
|
||||
// A receipt reads as books.get_receipt's meta view: what it is, never
|
||||
// its bytes (those come with view bytes).
|
||||
for (id, bytes) in [(r1, &b"first receipt"[..]), (r2, &b"second receipt"[..])] {
|
||||
let got = read(&mut m, &format!("books://receipts/{id}"));
|
||||
assert_eq!(got["attachment_id"], json!(id));
|
||||
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), bytes);
|
||||
assert!(got.get("content_base64").is_none(), "no bytes: {got}");
|
||||
assert_eq!(got["sha256"], sha256(bytes));
|
||||
assert_eq!(got["size_bytes"], json!(bytes.len()));
|
||||
assert_eq!(
|
||||
got,
|
||||
m.ok("books.get_receipt", json!({"attachment_id": id})),
|
||||
"books://receipts/{id} reads as books.get_receipt"
|
||||
);
|
||||
let full = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(b64d(full["content_base64"].as_str().unwrap()), bytes);
|
||||
}
|
||||
let r = m.rpc("resources/read", json!({"uri": "books://nope"}));
|
||||
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}"));
|
||||
@@ -2136,8 +2137,8 @@ fn resources_read_back_as_the_matching_tools() {
|
||||
|
||||
/// Receipts and documents round-trip: the bytes come back exactly (SHA-256
|
||||
/// of what was sent), with the name, MIME type, category, entry, and
|
||||
/// description given. Kills: get_receipt's lookup, import_local_file's
|
||||
/// document arm.
|
||||
/// description given; only view bytes carries the bytes. Kills:
|
||||
/// get_receipt's lookup and its views.
|
||||
#[test]
|
||||
fn receipts_and_documents_round_trip() {
|
||||
let dir = test_dir("files");
|
||||
@@ -2150,48 +2151,27 @@ fn receipts_and_documents_round_trip() {
|
||||
let a = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
"description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
);
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png",
|
||||
"content_base64": b64e(second), "description": "Operating agreement"}),
|
||||
"content_base64": b64e(second), "sha256": sha256(second),
|
||||
"description": "Operating agreement"}),
|
||||
);
|
||||
// Local files come from outside the books, in the server's import
|
||||
// folder (local_files.rs).
|
||||
let inbox = inbox(&dir);
|
||||
let local_doc = inbox.join("ein-letter.pdf");
|
||||
std::fs::write(&local_doc, b"EIN letter bytes").unwrap();
|
||||
let c = m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_doc.to_str().unwrap(), "category": "document",
|
||||
"document_category": "tax", "description": "IRS CP 575"}),
|
||||
"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"}),
|
||||
);
|
||||
assert_eq!(c["file_name"], "ein-letter.pdf");
|
||||
let local_receipt = inbox.join("scan.png");
|
||||
std::fs::write(&local_receipt, b"a scanned receipt").unwrap();
|
||||
let r = m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_receipt.to_str().unwrap(), "category": "receipt",
|
||||
"entry_id": e, "file_name": "renamed.png", "description": "scan"}),
|
||||
"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"}),
|
||||
);
|
||||
assert_eq!(r["file_name"], "renamed.png");
|
||||
m.refuse(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_receipt.to_str().unwrap(), "category": "photo"}),
|
||||
);
|
||||
// Nothing of the books is ever read back in through a path: outside
|
||||
// the import folders, it gets the one refusal any path there gets.
|
||||
let journal = data_dir(&dir).join(ledger_store::JOURNAL_FILE);
|
||||
for p in [journal, dir.join("wanderbooks-1000000.00.toml")] {
|
||||
assert!(p.exists());
|
||||
let why = m.refuse(
|
||||
"books.import_local_file",
|
||||
json!({"path": p.to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(why.contains("import folders"), "{why}");
|
||||
assert!(!why.contains(dir.to_str().unwrap()), "names a path: {why}");
|
||||
}
|
||||
|
||||
// (result, bytes, file name, MIME type, entry, description)
|
||||
type Case<'a> = (&'a Value, &'a [u8], &'a str, &'a str, Option<u64>, &'a str);
|
||||
@@ -2233,13 +2213,24 @@ fn receipts_and_documents_round_trip() {
|
||||
for (out, bytes, name, mime, entry, desc) in cases {
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
|
||||
let got = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
assert!(
|
||||
meta.get("content_base64").is_none(),
|
||||
"{name}: meta, no bytes"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
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");
|
||||
assert_eq!(got["sha256"], sha256(bytes));
|
||||
assert_eq!(got["file_name"], name);
|
||||
assert_eq!(got["mime_type"], mime);
|
||||
@@ -2263,19 +2254,26 @@ fn receipts_and_documents_round_trip() {
|
||||
assert_eq!(cat(&b), "legal");
|
||||
assert_eq!(cat(&c), "tax");
|
||||
m.refuse("books.get_receipt", json!({"attachment_id": 999}));
|
||||
for view in ["", "BYTES", "content", "meta "] {
|
||||
let why = m.refuse(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": a["attachment_id"], "view": view}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("meta (the default) or bytes"),
|
||||
"{view:?}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 25 MB (MiB) is the most a receipt or document may be: exactly the
|
||||
/// limit is accepted through every way in, one byte more refused (checked
|
||||
/// on the local-file path, where it costs no upload). Kills: the limit
|
||||
/// comparisons in attach_receipt, file_document, and import_local_file,
|
||||
/// and import_local_file's limit arithmetic (2 MiB must pass).
|
||||
/// 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.
|
||||
#[test]
|
||||
fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
const LIMIT: usize = 25 * 1024 * 1024;
|
||||
// Over 50 MB of books: removed however the test ends, with its inbox
|
||||
// (the system temp dir is often RAM, and a mutation run starts this
|
||||
// test hundreds of times).
|
||||
fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("limits");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
@@ -2284,18 +2282,19 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": b64e(&at)}),
|
||||
"content_base64": b64e(&at), "sha256": sha256(&at)}),
|
||||
);
|
||||
let at_id = out["attachment_id"].as_u64().unwrap();
|
||||
let doc = vec![b'b'; LIMIT];
|
||||
let out = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "big.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&doc)}),
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
|
||||
"sha256": sha256(&doc)}),
|
||||
);
|
||||
let doc_id = out["attachment_id"].as_u64().unwrap();
|
||||
let listed = m.ok("books.list_receipts", json!({}));
|
||||
for id in [at_id, doc_id] {
|
||||
for (id, bytes) in [(at_id, &at), (doc_id, &doc)] {
|
||||
let row = listed["receipts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -2304,19 +2303,195 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole");
|
||||
// And it comes back inline, whole.
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(&b64d(got["content_base64"].as_str().unwrap()), 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)}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "over.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&over),
|
||||
"sha256": sha256(&over)}),
|
||||
),
|
||||
] {
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(
|
||||
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
}
|
||||
|
||||
for (n, accepted) in [(2 * 1024 * 1024, true), (LIMIT, true), (LIMIT + 1, false)] {
|
||||
let path = inbox(&dir).join(format!("local-{n}.bin"));
|
||||
std::fs::write(&path, vec![b'c' ^ (n % 7) as u8; n]).unwrap();
|
||||
let args = json!({"path": path.to_str().unwrap(), "category": "receipt"});
|
||||
if accepted {
|
||||
m.ok("books.import_local_file", args);
|
||||
} else {
|
||||
m.refuse("books.import_local_file", args);
|
||||
/// 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. Over stdio there are no uploads: an upload_id is refused,
|
||||
/// and books.begin_upload answers only for bytes stored already (the
|
||||
/// attachment holding them). Every refusal stores nothing.
|
||||
#[test]
|
||||
fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
let dir = test_dir("inline");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let bytes: &[u8] = b"%PDF-1.4 a receipt";
|
||||
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();
|
||||
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())
|
||||
{
|
||||
args[k] = v.clone();
|
||||
}
|
||||
args
|
||||
};
|
||||
for (args, said) in [
|
||||
// Neither, both.
|
||||
(with(json!({})), "books.begin_upload"),
|
||||
(
|
||||
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": b64e(bytes), "sha256": "not hex"})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
(
|
||||
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
// An upload, over stdio.
|
||||
(with(json!({"upload_id": "u1"})), "takes files inline only"),
|
||||
] {
|
||||
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}");
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
// 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!(out["sha256"], sha256(bytes));
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
assert_ne!(journal(), before);
|
||||
// begin_upload: the bytes stored already name their attachment; new
|
||||
// ones need the books server.
|
||||
let up = |sha: String| {
|
||||
json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf",
|
||||
"file_name": "r.pdf", "purpose": "receipt"})
|
||||
};
|
||||
let got = m.ok("books.begin_upload", up(sha256(bytes)));
|
||||
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
|
||||
assert!(got.get("upload_id").is_none(), "{got}");
|
||||
assert!(got.get("command").is_none(), "{got}");
|
||||
let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase()));
|
||||
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
|
||||
let stored = journal();
|
||||
let why = m.refuse("books.begin_upload", up(sha256(other)));
|
||||
assert!(why.contains("takes files inline only"), "{why}");
|
||||
// The CSV imports take csv_text or an upload, never both, never
|
||||
// neither; an upload over stdio is refused.
|
||||
for (tool, base) in [
|
||||
(
|
||||
"books.import_bank_csv",
|
||||
json!({"bank_account_code": "1000", "default_offset_code": "4000"}),
|
||||
),
|
||||
("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": "u1"}), "takes files inline only"),
|
||||
] {
|
||||
let mut args = base.clone();
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let why = m.refuse(tool, args.clone());
|
||||
assert!(why.contains(said), "{tool} {args}: {why}");
|
||||
}
|
||||
}
|
||||
assert_eq!(journal(), stored, "a refusal stored something");
|
||||
}
|
||||
|
||||
/// books.get_receipt's view bytes gives the bytes of a file of at most
|
||||
/// 256 KiB; a larger one (filed before, or by an upload) is refused over
|
||||
/// stdio, where there is no download, while its meta view still answers.
|
||||
#[test]
|
||||
fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() {
|
||||
const 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_id, _, _) = s
|
||||
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
|
||||
.unwrap();
|
||||
let (over_id, _, _) = s
|
||||
.attach_receipt(None, "over.bin", "application/octet-stream", &over, None)
|
||||
.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);
|
||||
assert!(got.get("download").is_none(), "{got}");
|
||||
let why = m.refuse(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": over_id, "view": "bytes"}),
|
||||
);
|
||||
assert!(why.contains(&(LIMIT + 1).to_string()), "{why}");
|
||||
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}");
|
||||
assert!(meta.get("download").is_none(), "{meta}");
|
||||
}
|
||||
|
||||
/// Bank CSV import: money in debits the bank, money out credits it; the
|
||||
|
||||
@@ -360,7 +360,9 @@ impl Mcp {
|
||||
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"), "description": "Bill of sale"}),
|
||||
"content_base64": b64(b"bill of sale"),
|
||||
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
|
||||
"description": "Bill of sale"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
|
||||
@@ -371,6 +371,11 @@ fn b64(bytes: &[u8]) -> String {
|
||||
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"),
|
||||
@@ -406,7 +411,7 @@ impl Mcp {
|
||||
self.ok(
|
||||
"books.file_document",
|
||||
json!({"category": category, "file_name": format!("{category}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes),
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
|
||||
"link_to": link_to}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
@@ -416,7 +421,7 @@ 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)});
|
||||
"content_base64": b64(bytes), "sha256": sha(bytes)});
|
||||
if let Some(e) = entry {
|
||||
args["entry_id"] = json!(e);
|
||||
}
|
||||
@@ -787,25 +792,25 @@ 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"), "link_to": link_to}),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
|
||||
);
|
||||
}
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||||
"content_base64": b64(b"c"), "sha256": sha(b"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"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"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"),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"),
|
||||
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -819,7 +824,7 @@ fn filing_links_in_the_same_call_all_or_nothing() {
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||||
);
|
||||
assert_eq!(kinds(&doc), ["company"]);
|
||||
assert_eq!(m.count("document.filed"), 1);
|
||||
@@ -849,7 +854,7 @@ fn documents_file_under_the_attachment_kinds() {
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"x")}),
|
||||
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -213,14 +213,6 @@ fn test_dir(name: &str) -> TestDir {
|
||||
|
||||
/// A directory beside the books dir `dir`, outside it (vault keys, files
|
||||
/// with TINs).
|
||||
/// The import root beside the books dir `dir`: files the tests hand the
|
||||
/// server by path (books.import_local_file, books.set_w9 w9_file_path).
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let d = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn outside(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
@@ -260,10 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
// Local files come only from here (the default is the downloads
|
||||
// folder): never the books, never where the server writes.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
// books.show writes a viewer request here; keep it out of the
|
||||
// user's real runtime dir.
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
@@ -548,6 +536,7 @@ impl Mcp {
|
||||
"file_name": "receipt.txt",
|
||||
"mime_type": "text/plain",
|
||||
"content_base64": b64(bytes),
|
||||
"sha256": sha(bytes),
|
||||
});
|
||||
if let Some(e) = entry {
|
||||
args["entry_id"] = json!(e);
|
||||
@@ -660,6 +649,11 @@ 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();
|
||||
@@ -811,12 +805,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let w9_pdf = inbox(&dir).join("landlord-w9.pdf");
|
||||
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
|
||||
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin": "79-9714321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
|
||||
"tin": "79-9714321", "tin_type": "ein",
|
||||
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
|
||||
);
|
||||
m.ok(
|
||||
"books.get_w9",
|
||||
@@ -889,17 +883,23 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok("books.list_receipts", json!({"include_withdrawn": true}));
|
||||
m.ok("books.list_receipts", json!({"entry_id": e2}));
|
||||
m.ok("books.get_receipt", json!({"attachment_id": r_old}));
|
||||
m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": r_old, "view": "bytes"}),
|
||||
);
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"operating agreement"), "description": "OA"}),
|
||||
"content_base64": b64(b"operating agreement"),
|
||||
"sha256": sha(b"operating agreement"), "description": "OA"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let filed = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"lease"),
|
||||
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -915,13 +915,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.unlink_attachment",
|
||||
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
|
||||
);
|
||||
// From outside the books, in the server's import folder.
|
||||
let local = inbox(&dir).join("local-receipt.txt");
|
||||
std::fs::write(&local, b"a local receipt").unwrap();
|
||||
m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local.to_str().unwrap(), "category": "receipt"}),
|
||||
// An upload of bytes already stored: begin_upload names the
|
||||
// attachment that holds them (over stdio, the only upload answered).
|
||||
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",
|
||||
"file_name": "local-receipt.txt", "purpose": "receipt"}),
|
||||
);
|
||||
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
|
||||
|
||||
// Reversal: preview, then confirmed.
|
||||
let e3 = m.draft(
|
||||
@@ -1071,7 +1073,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
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())}),
|
||||
"content_base64": b64(csv.as_bytes()),
|
||||
"sha256": sha(csv.as_bytes())}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1092,7 +1095,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
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")}),
|
||||
"content_base64": b64(b"%PDF statement"),
|
||||
"sha256": sha(b"%PDF statement")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1572,7 +1576,8 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
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())}),
|
||||
"content_base64": b64(bytes.as_bytes()),
|
||||
"sha256": sha(bytes.as_bytes())}),
|
||||
);
|
||||
k.attachments.extend(new_id(&out, "attachment_id"));
|
||||
}
|
||||
@@ -1601,11 +1606,21 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
|
||||
"confirmed": opt(*confirmed)}),
|
||||
),
|
||||
Op::Attach { entry, content } => (
|
||||
"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}),
|
||||
),
|
||||
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))
|
||||
};
|
||||
(
|
||||
"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}),
|
||||
)
|
||||
}
|
||||
Op::Link { att, entry } => (
|
||||
"books.link_receipt",
|
||||
json!({"attachment_id": att.resolve(&k.attachments),
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Drop for Mcp {
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folders beside it (`-inbox`, `-outside`),
|
||||
/// A test's dir and the folder beside it (`-outside`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
@@ -92,11 +92,7 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in ["-inbox", "-outside"] {
|
||||
let p = PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +115,15 @@ fn outside(dir: &Path) -> PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// The server's import root, beside the books dir `dir`: W-9 files handed
|
||||
/// to books.set_w9 by path. Never where the server writes TINs.
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-inbox", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
/// `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)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name, the receipt threshold, and
|
||||
@@ -169,9 +168,6 @@ impl Mcp {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
// Files handed over by path come only from here.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
// Never the user's real vault key (the default key file is
|
||||
// under XDG_CONFIG_HOME); the tests name theirs anyway.
|
||||
@@ -519,11 +515,9 @@ impl Mcp {
|
||||
}
|
||||
self.tool("books.set_w9", args)
|
||||
}
|
||||
/// A W-9 file outside the books; it shows the TIN, like the real one.
|
||||
fn w9_file(&self, name: &str, tin: &str) -> PathBuf {
|
||||
let p = inbox(&self.books_dir()).join(name);
|
||||
std::fs::write(&p, format!("%PDF-1.4 Form W-9 TIN {tin}")).unwrap();
|
||||
p
|
||||
/// 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()
|
||||
}
|
||||
/// Pay `vendor` `amount` from the bank in 2026, tagged.
|
||||
fn pay(&mut self, vendor: &str, amount: &str) {
|
||||
@@ -626,9 +620,9 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
let mut m = books("refusals");
|
||||
let acme = m.party_id("Acme Design");
|
||||
m.add_party("IRS", &["tax_agency"]);
|
||||
let missing = inbox(&m.books_dir()).join("no-such-w9.pdf");
|
||||
let txt = inbox(&m.books_dir()).join("w9.txt");
|
||||
std::fs::write(&txt, "W-9").unwrap();
|
||||
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
|
||||
(
|
||||
@@ -653,14 +647,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
"Acme Design",
|
||||
json!({"tin": 291122846.0, "tin_type": "ssn"}),
|
||||
), // a float
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": missing.to_str().unwrap()}),
|
||||
),
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": txt.to_str().unwrap()}),
|
||||
),
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "clear": true}),
|
||||
@@ -680,6 +666,51 @@ 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);
|
||||
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": "u1"}), "takes files inline only"),
|
||||
(
|
||||
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}),
|
||||
"not both",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf), "w9_file_name": "../w9.pdf"}),
|
||||
"no folder",
|
||||
),
|
||||
] {
|
||||
let mut args = json!({"tin": SSN, "tin_type": "ssn"});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let why_given = m.w9("Acme Design", args).err_text();
|
||||
assert!(why_given.contains(why), "{extra}: {why_given}");
|
||||
assert!(!holds_tin(why_given.as_bytes(), SSN_DIGITS), "{why_given}");
|
||||
}
|
||||
assert!(
|
||||
!m.vault().join(format!("tin-{acme}.bin")).exists(),
|
||||
"no record written"
|
||||
@@ -739,10 +770,10 @@ fn the_facts_never_disagree_with_the_vault() {
|
||||
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("acme-w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
let Out::Ok(p) = m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
) else {
|
||||
panic!("set_w9 with a file")
|
||||
};
|
||||
@@ -760,8 +791,9 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["path"], json!(to.display().to_string()));
|
||||
assert_eq!(out["file_name"], "acme-w9.pdf");
|
||||
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
|
||||
// Given inline, the file is named for its type.
|
||||
assert_eq!(out["file_name"], "w9.pdf");
|
||||
assert_eq!(std::fs::read(&to).unwrap(), src);
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -804,10 +836,10 @@ 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("solo.png", "000");
|
||||
let solo = m.w9_file("000");
|
||||
let Out::Ok(p) = m.w9(
|
||||
"Solo",
|
||||
json!({"tin_last4": "0000", "w9_file_path": solo.to_str().unwrap()}),
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
|
||||
) else {
|
||||
panic!("a W-9 file alone")
|
||||
};
|
||||
@@ -815,22 +847,25 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
assert_eq!(p["w9"]["kept_at"], "wanderbooks vault");
|
||||
}
|
||||
|
||||
/// A W-9 file is a PDF, PNG or JPEG, by its extension in any case
|
||||
/// (.pdf, .png, .jpg, .jpeg): each is taken into the vault and comes back
|
||||
/// byte for byte, under its own name.
|
||||
/// A W-9 file is a PDF, PNG or JPEG, judged by its own first bytes: each
|
||||
/// is taken into the vault and comes back byte for byte, named for its
|
||||
/// type.
|
||||
#[test]
|
||||
fn every_w9_file_type_is_taken() {
|
||||
let mut m = books("w9-types");
|
||||
for (n, name) in ["w9.pdf", "w9.png", "w9.jpg", "w9.jpeg", "W9.JPG"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
for (n, (bytes, name)) in [
|
||||
(b"%PDF-1.4 Form W-9".as_slice(), "w9.pdf"),
|
||||
(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR W-9".as_slice(), "w9.png"),
|
||||
(b"\xff\xd8\xff\xe0\0\x10JFIF W-9".as_slice(), "w9.jpg"),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let party = format!("Payee {n}");
|
||||
m.add_party(&party, &["contractor"]);
|
||||
let src = m.w9_file(name, "000");
|
||||
let Out::Ok(p) = m.w9(
|
||||
&party,
|
||||
json!({"tin_last4": "0000", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
) else {
|
||||
panic!("{name} refused")
|
||||
};
|
||||
@@ -841,17 +876,57 @@ fn every_w9_file_type_is_taken() {
|
||||
json!({"party": party, "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], *name);
|
||||
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
|
||||
assert_eq!(std::fs::read(&to).unwrap(), *bytes);
|
||||
}
|
||||
// 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 Out::Ok(_) = m.w9(
|
||||
"Payee N",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes),
|
||||
"w9_file_name": "payee-n-w9.pdf"}),
|
||||
) else {
|
||||
panic!("named W-9 refused")
|
||||
};
|
||||
let to = m.outside().join("copy-named");
|
||||
let out = m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": "Payee N", "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], "payee-n-w9.pdf");
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee N",
|
||||
json!({"tin_last4": "0000", "w9_file_name": "x.pdf"}),
|
||||
)
|
||||
.err_text();
|
||||
assert!(why.contains("w9_file_name names the W-9"), "{why}");
|
||||
// Anything else is refused, near misses too.
|
||||
m.add_party("Payee X", &["contractor"]);
|
||||
for bytes in [
|
||||
b"PDF-1.4 no percent".as_slice(),
|
||||
b"\x89PNG\r\n no".as_slice(),
|
||||
b"\xff\xd8 short".as_slice(),
|
||||
b"GIF89a".as_slice(),
|
||||
] {
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee X",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
)
|
||||
.err_text();
|
||||
assert!(why.contains("PDF, PNG, or JPEG"), "{why}");
|
||||
}
|
||||
assert_eq!(m.party("Payee X", 2026)["party"]["w9"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_vault_says_so_and_never_guesses() {
|
||||
let mut m = books("locked");
|
||||
let src = m.w9_file("w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
m.pay("Acme Design", "700.00");
|
||||
let key = std::fs::read(m.key()).unwrap();
|
||||
@@ -1003,10 +1078,10 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
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("w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
// Clearing while the vault holds it: refused.
|
||||
let why = m
|
||||
@@ -1086,10 +1161,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
"no-leak",
|
||||
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
|
||||
);
|
||||
let src = m.w9_file("acme-w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
|
||||
@@ -240,187 +240,25 @@ fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Start the server and report whether it refused to start (it never got
|
||||
/// as far as serving), with its standard error.
|
||||
fn refuses_to_start(cmd: &mut Command) -> Option<String> {
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
/// `--import-root` is gone (files come in as tool input, P2 slice 5): a
|
||||
/// server started with it stops at once, as for any unknown argument,
|
||||
/// and never serves as if it had read from that folder.
|
||||
#[test]
|
||||
fn the_import_root_flag_is_refused_as_unknown() {
|
||||
let dir = temp("import-root");
|
||||
let out = Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--import-root")
|
||||
.arg(dir.join("Downloads"))
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config"))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
// A server that starts waits on stdin; closing it ends the server.
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
(!err.contains("serving over stdio")).then_some(err)
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0: a named import root that is `/`, the home
|
||||
/// folder, a folder holding it, a folder holding the books, the data dir
|
||||
/// (a symlinked --data-dir placed where it really is), the vault or a key
|
||||
/// file, or a folder inside the books, the data dir or the vault, stops
|
||||
/// the server at startup, saying why; a folder holding none of them is
|
||||
/// used.
|
||||
#[test]
|
||||
fn an_import_root_holding_the_books_or_the_home_folder_is_refused() {
|
||||
let tmp = temp("bad-roots");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
let books = home.join("work/books");
|
||||
let data = books.join("books-data");
|
||||
let keys = dir.join("keys");
|
||||
for d in [&home.join("Downloads"), &books, &keys, &dir.join("config")] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[vault]\nkey_file = \"{}\"\n",
|
||||
keys.join("vault.key").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let link = dir.join("data-link");
|
||||
std::os::unix::fs::symlink(&data, &link).unwrap();
|
||||
let start = |data: &std::path::Path, root: &std::path::Path| {
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.arg("--data-dir")
|
||||
.arg(data)
|
||||
.arg("--import-root")
|
||||
.arg(root)
|
||||
.env("HOME", &home)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config"));
|
||||
refuses_to_start(&mut cmd)
|
||||
};
|
||||
for (root, why) in [
|
||||
(std::path::PathBuf::from("/"), "whole filesystem"),
|
||||
(home.clone(), "home folder"),
|
||||
(dir.clone(), "home folder"),
|
||||
(home.join("work"), "holds the books"),
|
||||
(books.clone(), "holds the books"),
|
||||
(data.clone(), "holds the books"),
|
||||
(
|
||||
keys.clone(),
|
||||
"holds the books, the TIN vault, or a vault key",
|
||||
),
|
||||
(
|
||||
dir.join("config"),
|
||||
"holds the books, the TIN vault, or a vault key",
|
||||
),
|
||||
] {
|
||||
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
|
||||
assert!(
|
||||
err.contains("can't be used") && err.contains(why),
|
||||
"{}: {err}",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
// A folder inside the books, the data dir or the vault: every file in
|
||||
// it would be refused, so the root is.
|
||||
for root in [
|
||||
books.join("company"),
|
||||
data.join("blobs"),
|
||||
books.join("vault/inner"),
|
||||
] {
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
|
||||
assert!(
|
||||
err.contains("can't be used") && err.contains("inside the books"),
|
||||
"{}: {err}",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
// Through a symlinked data dir, the books are where they really are.
|
||||
let err = start(&link, &home.join("work")).expect("a root holding the real books");
|
||||
assert!(err.contains("holds the books"), "{err}");
|
||||
// A folder holding none of them is used.
|
||||
assert_eq!(start(&data, &home.join("Downloads")), None);
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0, the default: an XDG_DOWNLOAD_DIR of
|
||||
/// "$HOME/" (or "/") never makes the home folder an import root. With no
|
||||
/// real ~/Downloads there is no root, and an import says so; with one, it
|
||||
/// is the root, and the rest of the home folder stays out of reach.
|
||||
#[test]
|
||||
fn a_download_dir_of_home_never_opens_the_home_folder() {
|
||||
let tmp = temp("xdg-home");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
std::fs::create_dir_all(home.join(".ssh")).unwrap();
|
||||
std::fs::write(home.join(".ssh/id_ed25519"), "PRIVATE KEY").unwrap();
|
||||
let key = home.join(".ssh/id_ed25519");
|
||||
let import = |xdg: &str, path: &std::path::Path| {
|
||||
one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books/books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", xdg)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": path.to_str().unwrap(), "category": "document"}),
|
||||
)
|
||||
};
|
||||
for xdg in ["$HOME/", "$HOME", "/"] {
|
||||
let reply = import(xdg, &key);
|
||||
assert!(reply.contains("has no import folder"), "{xdg}: {reply}");
|
||||
}
|
||||
std::fs::create_dir_all(home.join("Downloads")).unwrap();
|
||||
std::fs::write(home.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
for xdg in ["$HOME/", "/"] {
|
||||
let reply = import(xdg, &key);
|
||||
assert!(
|
||||
reply.contains("no such file in the server's import folders"),
|
||||
"{xdg}: {reply}"
|
||||
);
|
||||
assert!(!reply.contains("PRIVATE"), "{reply}");
|
||||
}
|
||||
let reply = import("$HOME/", &home.join("Downloads/receipt.pdf"));
|
||||
assert!(reply.contains("attachment_id"), "{reply}");
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0, a refused default: a downloads folder
|
||||
/// that holds the books, or one inside them, is dropped, never fatal
|
||||
/// (only a named root stops the server): it serves, with no import
|
||||
/// folder, and an import says so.
|
||||
#[test]
|
||||
fn a_default_download_dir_holding_the_books_is_dropped_not_fatal() {
|
||||
let tmp = temp("xdg-books");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
let downloads = dir.join("downloads");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(downloads.join("books")).unwrap();
|
||||
std::fs::write(downloads.join("receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
let reply = one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(downloads.join("books/books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", &downloads)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": downloads.join("receipt.pdf").to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(reply.contains("has no import folder"), "{reply}");
|
||||
// A downloads folder inside the books dir is dropped the same way.
|
||||
let books = dir.join("books");
|
||||
std::fs::create_dir_all(books.join("Downloads")).unwrap();
|
||||
std::fs::write(books.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
let reply = one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(books.join("books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", books.join("Downloads"))
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": books.join("Downloads/receipt.pdf").to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(reply.contains("has no import folder"), "{reply}");
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("unknown argument: --import-root"), "{err}");
|
||||
assert!(!err.contains("serving"), "{err}");
|
||||
assert!(!dir.join("books-data").exists(), "opened the books");
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
//! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16).
|
||||
//! INVARIANTS (oracle):
|
||||
//! 1. Every file the server read or wrote with full TINs is refused by
|
||||
//! every way a file enters the books (books.import_local_file,
|
||||
//! books.attach_receipt, books.file_document), from any session and
|
||||
//! 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.
|
||||
//! 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
|
||||
@@ -59,8 +59,9 @@ impl Drop for Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The books dir, the import root, a folder outside both, and the
|
||||
/// settings (a member, so the CPA package has a K-1 worksheet).
|
||||
/// The books dir, a folder for files on their way in, a folder outside
|
||||
/// both, and the settings (a member, so the CPA package has a K-1
|
||||
/// worksheet).
|
||||
struct Books {
|
||||
dir: PathBuf,
|
||||
inbox: PathBuf,
|
||||
@@ -125,8 +126,6 @@ fn serve(b: &Books) -> (Server, String) {
|
||||
.arg(b.dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(&b.toml)
|
||||
.arg("--import-root")
|
||||
.arg(&b.inbox)
|
||||
.arg("--http")
|
||||
.arg(&addr)
|
||||
.env("XDG_CONFIG_HOME", base.join("config"))
|
||||
@@ -177,14 +176,17 @@ async fn ok(c: &Client, tool: &str, args: Value) -> Value {
|
||||
.unwrap_or_else(|e| panic!("{tool}: {e}"))
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
/// `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)
|
||||
(
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||
ledger_store::journal::sha256_hex(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every way the file at `path` (copied into the import root as `name`)
|
||||
/// could enter the books is refused with `why`, and nothing is stored:
|
||||
/// the refusals, tool by tool.
|
||||
/// Every way the file at `path` (named `name`) could enter the books is
|
||||
/// refused with `why`, and nothing is stored: the refusals, tool by tool.
|
||||
async fn refused_everywhere(
|
||||
c: &Client,
|
||||
b: &Books,
|
||||
@@ -193,23 +195,20 @@ async fn refused_everywhere(
|
||||
why: &str,
|
||||
) -> Vec<String> {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let copy = b.inbox.join(name);
|
||||
std::fs::copy(path, ©).unwrap();
|
||||
let (content, sha) = inline(&bytes);
|
||||
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
|
||||
let before = std::fs::read(&journal).unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.import_local_file",
|
||||
json!({"path": copy.to_str().unwrap(), "category": "document", "document_category": "tax"}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "application/octet-stream", "content_base64": b64(&bytes)}),
|
||||
json!({"file_name": name, "mime_type": "application/octet-stream",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf", "content_base64": b64(&bytes)}),
|
||||
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let err = call(c, tool, args).await.expect_err(tool);
|
||||
@@ -221,7 +220,6 @@ async fn refused_everywhere(
|
||||
before,
|
||||
"a refusal stored something"
|
||||
);
|
||||
std::fs::remove_file(copy).unwrap();
|
||||
refusals
|
||||
}
|
||||
|
||||
@@ -240,6 +238,7 @@ 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 (server, url) = serve(&b);
|
||||
let one = connect(&url).await;
|
||||
for (code, name, ty) in [
|
||||
@@ -270,7 +269,7 @@ 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_file_path": w9.to_str().unwrap()}),
|
||||
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}),
|
||||
)
|
||||
.await;
|
||||
ok(
|
||||
@@ -281,7 +280,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, &b, "statement.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
|
||||
// What the server writes with TINs, from the first session.
|
||||
let copy = b.out.join("w9-copy.pdf");
|
||||
ok(
|
||||
@@ -333,12 +332,12 @@ 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 = b.inbox.join("lunch.pdf");
|
||||
std::fs::write(&fine, b"%PDF-1.4\n\xff lunch receipt, order 12-34\n").unwrap();
|
||||
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
|
||||
ok(
|
||||
&two,
|
||||
"books.import_local_file",
|
||||
json!({"path": fine.to_str().unwrap(), "category": "receipt"}),
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": fine, "sha256": fine_sha}),
|
||||
)
|
||||
.await;
|
||||
// The books' own files the package carried are not marked: the
|
||||
@@ -353,7 +352,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
"the package carries the statement"
|
||||
);
|
||||
}
|
||||
accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
|
||||
drop((one, two));
|
||||
drop(server);
|
||||
|
||||
@@ -406,8 +405,9 @@ 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());
|
||||
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": SSN, "tin_type": "ssn", "w9_file_path": path.to_str().unwrap()})
|
||||
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha})
|
||||
};
|
||||
for (party, path, why) in [
|
||||
("Nobody", &w9, "unknown party"),
|
||||
@@ -424,51 +424,54 @@ async fn a_refused_set_w9_never_marks_its_file() {
|
||||
("w9-scan.pdf", w9_bytes.clone()),
|
||||
("notes.txt", std::fs::read(¬es).unwrap()),
|
||||
] {
|
||||
let (content, sha) = inline(&bytes);
|
||||
let out = ok(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": b64(&bytes)}),
|
||||
"content_base64": content, "sha256": sha}),
|
||||
)
|
||||
.await;
|
||||
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
|
||||
}
|
||||
// A set_w9 that succeeds marks it.
|
||||
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
|
||||
let err = call(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(&w9_bytes)}),
|
||||
)
|
||||
.await
|
||||
.expect_err("marked");
|
||||
assert!(err.contains(TIN_FILE), "{err}");
|
||||
let (content, sha) = inline(&w9_bytes);
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let err = call(&c, tool, args).await.expect_err("marked");
|
||||
assert!(err.contains(TIN_FILE), "{tool}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way a file enters the books takes the text `name` holding
|
||||
/// `text`, and the journal shows it.
|
||||
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
|
||||
let path = b.inbox.join(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
|
||||
let (content, sha) = inline(text.as_bytes());
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.import_local_file",
|
||||
json!({"path": path.to_str().unwrap(), "category": "document", "document_category": "statement"}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
|
||||
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
|
||||
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let out = ok(c, tool, args).await;
|
||||
assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// A real bank export: ABA routing numbers (nine digits), account and
|
||||
@@ -526,7 +529,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, &b, "bank.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
@@ -542,12 +545,11 @@ 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, &b, "members-0.csv", "member,tin\nKat,830835531\n").await;
|
||||
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await;
|
||||
}
|
||||
|
||||
/// INVARIANT 3b through the tools: the company's own EIN, as its
|
||||
@@ -571,7 +573,6 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
|
||||
let c = connect(&url).await;
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
&b,
|
||||
"plan.md",
|
||||
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
|
||||
)
|
||||
@@ -697,8 +698,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, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
|
||||
Reference in new issue
Block a user